[codex] Forward turn moderation metadata through app-server (#25710)

## Why
First-party backends can supply turn-scoped moderation metadata that
app-server clients need for client-side presentation. Exposing this as
an experimental typed notification lets opted-in clients consume it
without interpreting raw Responses API events.

## What changed
- forward `response.metadata.openai_chatgpt_moderation_metadata` from
Responses API SSE and WebSocket streams as turn-scoped moderation
metadata
- emit the experimental app-server v2 `turn/moderationMetadata`
notification with `{ threadId, turnId, metadata }`
- add app-server integration coverage for the typed moderation metadata
notification

## Testing
- `just test -p codex-core
build_ws_client_metadata_includes_window_lineage_and_turn_metadata`
- `just test -p codex-core` (fails locally: 46 failures and 1 timeout,
primarily missing `test_stdio_server` and shell snapshot timeouts)
- `just test -p codex-app-server-protocol`
- `just test -p codex-app-server
turn_moderation_metadata_emits_typed_notification_v2`
- `just test -p codex-app-server` (fails locally: 792 passed, 10 failed,
and 5 timed out; failures are in existing environment-sensitive tests,
primarily because nested macOS `sandbox-exec` is not permitted)
- `just write-app-server-schema --experimental --schema-root
/tmp/codex-app-server-schema-experimental`
This commit is contained in:
carlc-oai
2026-06-05 02:41:06 -07:00
committed by GitHub
Unverified
parent 6dc28ba6e0
commit 55aa071b17
26 changed files with 395 additions and 2 deletions
@@ -4909,6 +4909,23 @@
}
]
},
"TurnModerationMetadataNotification": {
"properties": {
"metadata": true,
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"metadata",
"threadId",
"turnId"
],
"type": "object"
},
"TurnPlanStep": {
"properties": {
"status": {
@@ -6248,6 +6265,26 @@
"title": "Model/verificationNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"turn/moderationMetadata"
],
"title": "Turn/moderationMetadataNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/TurnModerationMetadataNotification"
}
},
"required": [
"method",
"params"
],
"title": "Turn/moderationMetadataNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -4904,6 +4904,26 @@
"title": "Model/verificationNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"turn/moderationMetadata"
],
"title": "Turn/moderationMetadataNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/TurnModerationMetadataNotification"
}
},
"required": [
"method",
"params"
],
"title": "Turn/moderationMetadataNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -18601,6 +18621,25 @@
}
]
},
"TurnModerationMetadataNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"metadata": true,
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"metadata",
"threadId",
"turnId"
],
"title": "TurnModerationMetadataNotification",
"type": "object"
},
"TurnPlanStep": {
"properties": {
"status": {
@@ -12420,6 +12420,26 @@
"title": "Model/verificationNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"turn/moderationMetadata"
],
"title": "Turn/moderationMetadataNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/TurnModerationMetadataNotification"
}
},
"required": [
"method",
"params"
],
"title": "Turn/moderationMetadataNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -16418,6 +16438,25 @@
}
]
},
"TurnModerationMetadataNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"metadata": true,
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"metadata",
"threadId",
"turnId"
],
"title": "TurnModerationMetadataNotification",
"type": "object"
},
"TurnPlanStep": {
"properties": {
"status": {
@@ -0,0 +1,19 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"metadata": true,
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"metadata",
"threadId",
"turnId"
],
"title": "TurnModerationMetadataNotification",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "../serde_json/JsonValue";
export type TurnModerationMetadataNotification = { threadId: string, turnId: string, metadata: JsonValue, };
@@ -447,6 +447,7 @@ export type { TurnError } from "./TurnError";
export type { TurnInterruptParams } from "./TurnInterruptParams";
export type { TurnInterruptResponse } from "./TurnInterruptResponse";
export type { TurnItemsView } from "./TurnItemsView";
export type { TurnModerationMetadataNotification } from "./TurnModerationMetadataNotification";
export type { TurnPlanStep } from "./TurnPlanStep";
export type { TurnPlanStepStatus } from "./TurnPlanStepStatus";
export type { TurnPlanUpdatedNotification } from "./TurnPlanUpdatedNotification";
@@ -1549,6 +1549,8 @@ server_notification_definitions! {
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification),
ModelVerification => "model/verification" (v2::ModelVerificationNotification),
#[experimental("turn/moderationMetadata")]
TurnModerationMetadata => "turn/moderationMetadata" (v2::TurnModerationMetadataNotification),
Warning => "warning" (v2::WarningNotification),
GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification),
DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification),
@@ -3222,6 +3224,21 @@ mod tests {
);
}
#[test]
fn turn_moderation_metadata_notification_is_marked_experimental() {
let notification =
ServerNotification::TurnModerationMetadata(v2::TurnModerationMetadataNotification {
thread_id: "thr_123".to_string(),
turn_id: "turn_123".to_string(),
metadata: json!({"presentation": "inline"}),
});
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&notification),
Some("turn/moderationMetadata")
);
}
#[test]
fn thread_realtime_started_notification_is_marked_experimental() {
let notification =
@@ -8,6 +8,7 @@ use codex_protocol::protocol::ModelVerification as CoreModelVerification;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use ts_rs::TS;
v2_enum_from_core!(
@@ -152,3 +153,12 @@ pub struct ModelVerificationNotification {
pub turn_id: String,
pub verifications: Vec<ModelVerification>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct TurnModerationMetadataNotification {
pub thread_id: String,
pub turn_id: String,
pub metadata: JsonValue,
}
+1
View File
@@ -1244,6 +1244,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn
- `turn/plan/updated``{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`.
- `model/rerouted``{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks).
- `model/verification``{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`.
- `turn/moderationMetadata` — experimental; `{ threadId, turnId, metadata }` when a first-party backend supplies turn-scoped moderation metadata for client-side presentation.
Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed.
@@ -75,6 +75,7 @@ use codex_app_server_protocol::TurnDiffUpdatedNotification;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnModerationMetadataNotification;
use codex_app_server_protocol::TurnPlanStep;
use codex_app_server_protocol::TurnPlanUpdatedNotification;
use codex_app_server_protocol::TurnStartedNotification;
@@ -337,6 +338,16 @@ pub(crate) async fn apply_bespoke_event_handling(
.send_server_notification(ServerNotification::ModelVerification(notification))
.await;
}
EventMsg::TurnModerationMetadata(event) => {
let notification = TurnModerationMetadataNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
metadata: event.metadata,
};
outgoing
.send_server_notification(ServerNotification::TurnModerationMetadata(notification))
.await;
}
EventMsg::RealtimeConversationStarted(event) => {
let notification = ThreadRealtimeStartedNotification {
thread_id: conversation_id.to_string(),
@@ -725,6 +725,7 @@ mod tests {
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::ServerResponse;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::TurnModerationMetadataNotification;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -951,6 +952,31 @@ mod tests {
);
}
#[test]
fn verify_turn_moderation_metadata_notification_serialization() {
let notification =
ServerNotification::TurnModerationMetadata(TurnModerationMetadataNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
metadata: json!({"presentation": "inline"}),
});
let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification);
assert_eq!(
json!({
"method": "turn/moderationMetadata",
"params": {
"threadId": "thread-1",
"turnId": "turn-1",
"metadata": {"presentation": "inline"},
},
}),
serde_json::to_value(jsonrpc_notification)
.expect("ensure the notification serializes correctly"),
"ensure the notification serializes correctly"
);
}
#[test]
fn server_request_response_from_result_decodes_typed_response() {
let request = ServerRequest::CommandExecutionRequestApproval {
@@ -15,6 +15,7 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnModerationMetadataNotification;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
@@ -309,6 +310,87 @@ async fn model_verification_emits_typed_notification_and_warning_v2() -> Result<
Ok(())
}
#[tokio::test]
async fn turn_moderation_metadata_emits_typed_notification_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let body = responses::sse(vec![
responses::ev_response_created("resp-1"),
serde_json::json!({
"type": "response.metadata",
"sequence_number": 1,
"response_id": "resp-1",
"metadata": {
"openai_chatgpt_moderation_metadata": {
"presentation": "inline"
}
}
}),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let response = responses::sse_response(body);
let _response_mock = responses::mount_response_once(&server, response).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some(REQUESTED_MODEL.to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![UserInput::Text {
text: "trigger moderation metadata".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let turn_start: TurnStartResponse = to_response(turn_resp)?;
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/moderationMetadata"),
)
.await??;
let metadata: TurnModerationMetadataNotification =
serde_json::from_value(notification.params.ok_or_else(|| {
anyhow::anyhow!("turn/moderationMetadata notifications must include params")
})?)?;
assert_eq!(
metadata,
TurnModerationMetadataNotification {
thread_id: thread.id,
turn_id: turn_start.turn.id,
metadata: serde_json::json!({"presentation": "inline"}),
}
);
Ok(())
}
async fn collect_turn_notifications_and_validate_no_warning_item(
mcp: &mut TestAppServer,
) -> Result<ModelReroutedNotification> {
+3
View File
@@ -6,6 +6,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::ModelVerification;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TurnModerationMetadataEvent;
use codex_protocol::protocol::W3cTraceContext;
use futures::Stream;
use serde::Deserialize;
@@ -78,6 +79,8 @@ pub enum ResponseEvent {
ServerModel(String),
/// Emitted when the server recommends additional account verification.
ModelVerifications(Vec<ModelVerification>),
/// Emitted when the server includes moderation metadata for first-party turn presentation.
TurnModerationMetadata(TurnModerationMetadataEvent),
/// Emitted when `X-Reasoning-Included: true` is present on the response,
/// meaning the server already accounted for past reasoning tokens and the
/// client should not re-estimate them.
@@ -683,6 +683,7 @@ async fn run_websocket_response_stream(
}
};
let model_verifications = event.model_verifications();
let turn_moderation_metadata = event.turn_moderation_metadata();
if event.kind() == "codex.rate_limits" {
if let Some(snapshot) = parse_rate_limit_event(&text) {
let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await;
@@ -707,6 +708,16 @@ async fn run_websocket_response_stream(
"response event consumer dropped".to_string(),
));
}
if let Some(metadata) = turn_moderation_metadata
&& tx_event
.send(Ok(ResponseEvent::TurnModerationMetadata(metadata)))
.await
.is_err()
{
return Err(ApiError::Stream(
"response event consumer dropped".to_string(),
));
}
match process_responses_event(event) {
Ok(Some(event)) => {
let is_completed = matches!(event, ResponseEvent::Completed { .. });
+57
View File
@@ -8,6 +8,7 @@ use codex_client::StreamResponse;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ModelVerification;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TurnModerationMetadataEvent;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use serde::Deserialize;
@@ -193,6 +194,18 @@ impl ResponsesStreamEvent {
.and_then(|metadata| metadata.get("openai_verification_recommendation"))
.and_then(model_verifications_from_json_value)
}
pub(crate) fn turn_moderation_metadata(&self) -> Option<TurnModerationMetadataEvent> {
if self.kind() != "response.metadata" {
return None;
}
self.metadata
.as_ref()
.and_then(|metadata| metadata.get("openai_chatgpt_moderation_metadata"))
.cloned()
.map(|metadata| TurnModerationMetadataEvent { metadata })
}
}
fn header_openai_model_value_from_json(value: &Value) -> Option<String> {
@@ -444,6 +457,7 @@ pub async fn process_sse(
}
};
let model_verifications = event.model_verifications();
let turn_moderation_metadata = event.turn_moderation_metadata();
if let Some(model) = event.response_model()
&& last_server_model.as_deref() != Some(model.as_str())
@@ -465,6 +479,14 @@ pub async fn process_sse(
{
return;
}
if let Some(metadata) = turn_moderation_metadata
&& tx_event
.send(Ok(ResponseEvent::TurnModerationMetadata(metadata)))
.await
.is_err()
{
return;
}
match process_responses_event(event) {
Ok(Some(event)) => {
@@ -1215,6 +1237,41 @@ mod tests {
);
}
#[tokio::test]
async fn process_sse_emits_turn_moderation_metadata_field() {
let events = run_sse(vec![
json!({
"type": "response.metadata",
"metadata": {
"openai_chatgpt_moderation_metadata": {
"presentation": "inline"
}
}
}),
json!({
"type": "response.completed",
"response": {
"id": "resp-1"
}
}),
])
.await;
assert_matches!(
&events[0],
ResponseEvent::TurnModerationMetadata(result)
if result.metadata == json!({"presentation": "inline"})
);
assert_matches!(
&events[1],
ResponseEvent::Completed {
response_id,
token_usage: None,
end_turn: None,
} if response_id == "resp-1"
);
}
#[test]
fn responses_stream_event_response_model_reads_top_level_headers() {
let ev: ResponsesStreamEvent = serde_json::from_value(json!({
+10
View File
@@ -359,6 +359,7 @@ use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::TurnModerationMetadataEvent;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolEnvironmentMode;
@@ -2637,6 +2638,15 @@ impl Session {
.await;
}
pub(crate) async fn emit_turn_moderation_metadata(
self: &Arc<Self>,
turn_context: &Arc<TurnContext>,
metadata: TurnModerationMetadataEvent,
) {
self.send_event(turn_context, EventMsg::TurnModerationMetadata(metadata))
.await;
}
pub(crate) async fn replace_history(
&self,
items: Vec<ResponseItem>,
+5
View File
@@ -1392,6 +1392,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::RealtimeConversationClosed(_)
| EventMsg::ModelReroute(_)
| EventMsg::ModelVerification(_)
| EventMsg::TurnModerationMetadata(_)
| EventMsg::ContextCompacted(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::TurnStarted(_)
@@ -2057,6 +2058,10 @@ async fn try_run_sampling_request(
.await;
}
}
ResponseEvent::TurnModerationMetadata(metadata) => {
sess.emit_turn_moderation_metadata(&turn_context, metadata)
.await;
}
ResponseEvent::ServerReasoningIncluded(included) => {
sess.set_server_reasoning_included(included).await;
}
+1
View File
@@ -147,6 +147,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool {
ResponseEvent::Created
| ResponseEvent::ServerModel(_)
| ResponseEvent::ModelVerifications(_)
| ResponseEvent::TurnModerationMetadata(_)
| ResponseEvent::ServerReasoningIncluded(_)
| ResponseEvent::ToolCallInputDelta { .. }
| ResponseEvent::Completed { .. }
+2 -1
View File
@@ -266,7 +266,8 @@ async fn run_codex_tool_session_inner(
}
EventMsg::Warning(_)
| EventMsg::GuardianWarning(_)
| EventMsg::ModelVerification(_) => {
| EventMsg::ModelVerification(_)
| EventMsg::TurnModerationMetadata(_) => {
continue;
}
EventMsg::GuardianAssessment(_) => {
@@ -1208,6 +1208,7 @@ impl SessionTelemetry {
}
ResponseEvent::ServerModel(_) => "server_model".into(),
ResponseEvent::ModelVerifications(_) => "model_verifications".into(),
ResponseEvent::TurnModerationMetadata(_) => "turn_moderation_metadata".into(),
ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(),
ResponseEvent::RateLimits(_) => "rate_limits".into(),
ResponseEvent::ModelsEtag(_) => "models_etag".into(),
+8
View File
@@ -1221,6 +1221,9 @@ pub enum EventMsg {
/// Backend recommends additional account verification for this turn.
ModelVerification(ModelVerificationEvent),
/// Backend moderation metadata intended for first-party turn presentation.
TurnModerationMetadata(TurnModerationMetadataEvent),
/// Conversation history was compacted (either automatically or manually).
ContextCompacted(ContextCompactedEvent),
@@ -1885,6 +1888,11 @@ pub struct ModelVerificationEvent {
pub verifications: Vec<ModelVerification>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct TurnModerationMetadataEvent {
pub metadata: Value,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ContextCompactedEvent;
@@ -224,6 +224,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option<ToolRuntimeTr
| EventMsg::RealtimeConversationSdp(_)
| EventMsg::ModelReroute(_)
| EventMsg::ModelVerification(_)
| EventMsg::TurnModerationMetadata(_)
| EventMsg::ContextCompacted(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::ThreadGoalUpdated(_)
@@ -297,6 +298,7 @@ pub(crate) fn wrapped_protocol_event_type(event: &EventMsg) -> Option<&'static s
| EventMsg::RealtimeConversationSdp(_)
| EventMsg::ModelReroute(_)
| EventMsg::ModelVerification(_)
| EventMsg::TurnModerationMetadata(_)
| EventMsg::ContextCompacted(_)
| EventMsg::ThreadSettingsApplied(_)
| EventMsg::TokenCount(_)
+1
View File
@@ -117,6 +117,7 @@ pub fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::RealtimeConversationClosed(_)
| EventMsg::ModelReroute(_)
| EventMsg::ModelVerification(_)
| EventMsg::TurnModerationMetadata(_)
| EventMsg::AgentReasoningSectionBreak(_)
| EventMsg::RawResponseItem(_)
| EventMsg::SessionConfigured(_)
@@ -118,6 +118,9 @@ pub(super) fn server_notification_thread_target(
ServerNotification::ModelVerification(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::TurnModerationMetadata(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeStarted(notification) => {
Some(notification.thread_id.as_str())
}
+1
View File
@@ -233,6 +233,7 @@ impl ChatWidget {
| ServerNotification::RemoteControlStatusChanged(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::FsChanged(_)
| ServerNotification::TurnModerationMetadata(_)
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
| ServerNotification::ThreadRealtimeTranscriptDelta(_)