Expose resumable misalignment details through app server (#40952)

## Why

App-server clients need the explanation and steering instruction returned with a
misalignment policy violation to offer a user-confirmed continuation. Missing or
incomplete details must continue to behave as a terminal block.

## What changed

- Parse optional misalignment classification, explanation, and steering details
  from streamed and HTTP Responses errors and propagate them through core errors.
- Include the details in live app-server `error` and `turn/completed` payloads and
  export the corresponding protocol schemas and TypeScript types.
- Keep explanations and steering messages out of serialized rollout events and
  redact them from debug output.
- Document how clients can resume with `turn/start` after user confirmation.

## Testing

- Cover streamed, HTTP, and WebSocket-wrapped errors, including malformed and
  classification-only details.
- Verify live app-server notifications expose resumable details without writing
  the explanation or steering message to the rollout.

GitOrigin-RevId: 329258a444c2cd91d0c57ab1720830b33ddcfac5
This commit is contained in:
Francis Chalissery authored and copyberry committed 2026-08-26 18:42:44 +00:00
1 parent 0e9a2bae52
commit 7276d67081
63 files changed
+1604 -15

No files matched your search

+90 -3
View File
@@ -9,6 +9,7 @@ use crate::telemetry::SseTelemetry;
use codex_client::ByteStream;
use codex_client::StreamResponse;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::MisalignmentErrorDetails;
use codex_protocol::protocol::ModelVerification;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TurnModerationMetadataEvent;
@@ -107,6 +108,8 @@ struct Error {
message: Option<String>,
plan_type: Option<String>,
resets_at: Option<i64>,
#[serde(default)]
misalignment: Option<Value>,
}
#[derive(Debug, Deserialize)]
@@ -428,7 +431,12 @@ pub fn process_responses_event(
"This request was blocked due to a misalignment policy violation."
.to_string()
});
response_error = ApiError::MisalignmentPolicyViolation { message };
response_error = ApiError::MisalignmentPolicyViolation {
message,
misalignment: error.misalignment.and_then(|details| {
serde_json::from_value::<MisalignmentErrorDetails>(details).ok()
}),
};
} else if matches!(error.code.as_deref(), Some("invalid_prompt" | "bio_policy"))
{
let message = error
@@ -1212,8 +1220,12 @@ mod tests {
assert_eq!(events.len(), 1);
match &events[0] {
Err(ApiError::MisalignmentPolicyViolation { message }) => {
Err(ApiError::MisalignmentPolicyViolation {
message,
misalignment,
}) => {
assert_eq!(message, "This request violated the misalignment policy.");
assert_eq!(misalignment, &None);
}
other => panic!("unexpected event: {other:?}"),
}
@@ -1239,7 +1251,7 @@ mod tests {
assert_eq!(events.len(), 1);
match &events[0] {
Err(ApiError::MisalignmentPolicyViolation { message }) => assert_eq!(
Err(ApiError::MisalignmentPolicyViolation { message, .. }) => assert_eq!(
message,
"This request was blocked due to a misalignment policy violation."
),
@@ -1248,6 +1260,78 @@ mod tests {
}
}
#[tokio::test]
async fn misalignment_policy_violation_preserves_public_continuation_details() {
let raw_error = json!({
"type": "response.failed",
"response": {
"id": "resp_fatal_misalignment",
"status": "failed",
"error": {
"code": "misalignment_policy_violation",
"message": "This request violated the misalignment policy.",
"misalignment": {
"error_type": "future_safety_category",
"detailed_explanation": "The agent attempted an external transfer.",
"steer": { "message": "Do not transfer the user's files." }
}
}
}
});
let sse = format!("event: response.failed\ndata: {raw_error}\n\n");
let events = collect_events(&[sse.as_bytes()]).await;
assert_eq!(events.len(), 1);
match &events[0] {
Err(ApiError::MisalignmentPolicyViolation {
message,
misalignment,
}) => {
assert_eq!(message, "This request violated the misalignment policy.");
assert_eq!(
misalignment,
&Some(MisalignmentErrorDetails {
error_type: Some("future_safety_category".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(),
}),
})
);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[tokio::test]
async fn malformed_misalignment_details_preserve_the_fatal_policy_error() {
let raw_error = json!({
"type": "response.failed",
"response": {
"id": "resp_fatal_misalignment",
"status": "failed",
"error": {
"code": "misalignment_policy_violation",
"message": "This request violated the misalignment policy.",
"misalignment": { "steer": { "message": 42 } }
}
}
});
let sse = format!("event: response.failed\ndata: {raw_error}\n\n");
let events = collect_events(&[sse.as_bytes()]).await;
assert_eq!(events.len(), 1);
assert_matches!(
&events[0],
Err(ApiError::MisalignmentPolicyViolation {
misalignment: None,
..
})
);
}
#[tokio::test]
async fn content_policy_errors_without_type_are_invalid_requests() {
for (code, expected_message) in [
@@ -1913,6 +1997,7 @@ mod tests {
code: Some("rate_limit_exceeded".to_string()),
plan_type: None,
resets_at: None,
misalignment: None,
};
let delay = try_parse_retry_after(&err);
@@ -1927,6 +2012,7 @@ mod tests {
code: Some("rate_limit_exceeded".to_string()),
plan_type: None,
resets_at: None,
misalignment: None,
};
let delay = try_parse_retry_after(&err);
assert_eq!(delay, Some(Duration::from_secs_f64(1.898)));
@@ -1940,6 +2026,7 @@ mod tests {
code: Some("rate_limit_exceeded".to_string()),
plan_type: None,
resets_at: None,
misalignment: None,
};
let delay = try_parse_retry_after(&err);
assert_eq!(delay, Some(Duration::from_secs(35)));