Preserve display instructions in answer moderation (#60172)

## Why

Recovered answer history needs presentation instructions alongside the moderation flag so clients can apply the intended display behavior.

## What changed

- Add optional `display` metadata with an `action` and optional `blockType`, `cyberTrustedAccess`, and `checkTrustedContacts` fields.
- Carry display metadata through final answers, legacy events, thread history reconstruction, and legacy rollout migration.
- Expose the metadata in the app-server protocol and update generated JSON schemas, TypeScript types, and Python SDK models. Missing display fields remain compatible with existing stored moderation data; the app-server emits absent optional values as `null`.

## Testing

Extend the final-answer moderation test to cover display metadata in answer items and legacy events. Add serialization coverage for missing display data, nullable fields, and preservation of an explicit `false` value.

GitOrigin-RevId: b798a2ecc53c2a94c7659ef3d9663b3143b70a1a
This commit is contained in:
psachdeva-oai
2026-11-07 21:31:08 +00:00
committed by copyberry
parent 33b3f80a09
commit 202b590e84
35 changed files with 948 additions and 9 deletions
@@ -638,6 +638,7 @@ impl ThreadHistoryBuilder {
.as_ref()
.map(|decision| AgentMessageModeration {
moderated: decision.moderated,
display: decision.display.clone().map(Into::into),
}),
id,
text: payload.message.clone(),
@@ -28,6 +28,7 @@ use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianA
use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource;
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
pub use codex_protocol::items::AgentMessageDelivery;
use codex_protocol::items::AgentMessageDisplay as CoreAgentMessageDisplay;
use codex_protocol::items::AgentMessageModeration as CoreAgentMessageModeration;
pub use codex_protocol::items::AsyncUserInputQuestion;
use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool;
@@ -150,17 +151,42 @@ pub enum CommandAction {
},
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AgentMessageModeration {
pub moderated: bool,
pub display: Option<AgentMessageDisplay>,
}
impl From<CoreAgentMessageModeration> for AgentMessageModeration {
fn from(value: CoreAgentMessageModeration) -> Self {
Self {
moderated: value.moderated,
display: value.display.map(Into::into),
}
}
}
/// A presentation instruction for Chat to apply to this answer when recovering history.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AgentMessageDisplay {
/// Unknown actions must be left for the producing client to handle.
pub action: String,
pub block_type: Option<String>,
pub cyber_trusted_access: Option<bool>,
pub check_trusted_contacts: Option<bool>,
}
impl From<CoreAgentMessageDisplay> for AgentMessageDisplay {
fn from(value: CoreAgentMessageDisplay) -> Self {
Self {
action: value.action,
block_type: value.block_type,
cyber_trusted_access: value.cyber_trusted_access,
check_trusted_contacts: value.check_trusted_contacts,
}
}
}
@@ -3170,6 +3170,35 @@ fn audio_references_round_trip_through_core_and_json() {
);
}
#[test]
fn answer_moderation_exposes_nullable_display_fields() {
for (stored, expected) in [
(
json!({"moderated": false}),
json!({"moderated": false, "display": null}),
),
(
json!({"moderated": true, "display": {
"action": "answerHelpline",
"checkTrustedContacts": false,
}}),
json!({"moderated": true, "display": {
"action": "answerHelpline",
"blockType": null,
"cyberTrustedAccess": null,
"checkTrustedContacts": false,
}}),
),
] {
let core: codex_protocol::items::AgentMessageModeration =
serde_json::from_value(stored).unwrap();
assert_eq!(
serde_json::to_value(AgentMessageModeration::from(core)).unwrap(),
expected
);
}
}
#[test]
fn core_turn_item_into_thread_item_converts_supported_variants() {
let user_item = TurnItem::UserMessage(UserMessageItem {
@@ -3297,7 +3326,10 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
);
let agent_item_with_phase = TurnItem::AgentMessage(AgentMessageItem {
moderation: Some(codex_protocol::items::AgentMessageModeration { moderated: true }),
moderation: Some(codex_protocol::items::AgentMessageModeration {
moderated: true,
display: None,
}),
id: "agent-2".to_string(),
content: vec![AgentMessageContent::Text {
text: "final".to_string(),
@@ -3319,7 +3351,10 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
assert_eq!(
ThreadItemPayload::from(agent_item_with_phase),
ThreadItemPayload::AgentMessage {
moderation: Some(AgentMessageModeration { moderated: true }),
moderation: Some(AgentMessageModeration {
moderated: true,
display: None
}),
id: "agent-2".to_string(),
text: "final".to_string(),
phase: Some(MessagePhase::FinalAnswer),