Carry targeted moderation decisions on final-answer messages (#59842)

## What changed

Read `final_answer_moderation` metadata and attach `moderation: { moderated: boolean }` to the completed final-answer message only when its item ID matches. Apply this in both normal and plan modes, and reset the pending decision when a new response starts.

Preserve the answer's native item ID and moderation flag through legacy events and history reconstruction. Combine text parts into one legacy event when a moderation decision is present.

Expose nullable moderation data on app-server agent messages and update the JSON schemas, TypeScript types, and Python models.

## Testing

Add an integration test for moderation on completed answers and their legacy events. Extend protocol conversion tests to cover populated moderation data and serialization as `null` when absent.

GitOrigin-RevId: f286b67731a84b3daf5d10b7644f2e31b62f0e53
This commit is contained in:
psachdeva-oai
2026-11-06 07:43:44 +00:00
committed by copyberry
parent 55897425e8
commit ddd1812a51
98 changed files with 802 additions and 39 deletions
@@ -109,6 +109,7 @@ pub(super) fn item_summary(item: cloud::ItemSummary) -> Option<protocol::ThreadI
text,
questions,
} => protocol::ThreadItemPayload::AgentMessage {
moderation: None,
id,
text,
phase: None,
@@ -57,6 +57,7 @@ fn completed_reply_preserves_the_full_text_questions_and_timestamp() -> Result<(
"thread".into(),
"turn".into(),
protocol::ThreadItemPayload::AgentMessage {
moderation: None,
id: "reply".into(),
text: "Choose one".into(),
phase: None,
+1
View File
@@ -1146,6 +1146,7 @@ mod tests {
"thread".to_string(),
"turn".to_string(),
codex_app_server_protocol::ThreadItemPayload::AgentMessage {
moderation: None,
id: "item".to_string(),
text: text.to_string(),
phase: None,
@@ -177,6 +177,17 @@
],
"type": "object"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AgentPath": {
"type": "string"
},
@@ -4750,6 +4761,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -7387,6 +7387,17 @@
}
]
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AgentPath": {
"type": "string"
},
@@ -23438,6 +23449,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/v2/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -525,6 +525,17 @@
}
]
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AgentPath": {
"type": "string"
},
@@ -21141,6 +21152,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AsyncUserInputQuestion": {
"additionalProperties": false,
"properties": {
@@ -886,6 +897,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AsyncUserInputQuestion": {
"additionalProperties": false,
"properties": {
@@ -886,6 +897,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AsyncUserInputQuestion": {
"additionalProperties": false,
"properties": {
@@ -1091,6 +1102,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -31,6 +31,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AgentPath": {
"type": "string"
},
@@ -1705,6 +1716,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AsyncUserInputQuestion": {
"additionalProperties": false,
"properties": {
@@ -886,6 +897,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AgentPath": {
"type": "string"
},
@@ -1496,6 +1507,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AgentPath": {
"type": "string"
},
@@ -1496,6 +1507,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AgentPath": {
"type": "string"
},
@@ -1496,6 +1507,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -31,6 +31,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AgentPath": {
"type": "string"
},
@@ -1757,6 +1768,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AgentPath": {
"type": "string"
},
@@ -1496,6 +1507,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -31,6 +31,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AgentPath": {
"type": "string"
},
@@ -1705,6 +1716,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AgentPath": {
"type": "string"
},
@@ -1496,6 +1507,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AsyncUserInputQuestion": {
"additionalProperties": false,
"properties": {
@@ -1091,6 +1102,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AgentPath": {
"type": "string"
},
@@ -1496,6 +1507,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AsyncUserInputQuestion": {
"additionalProperties": false,
"properties": {
@@ -1091,6 +1102,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AsyncUserInputQuestion": {
"additionalProperties": false,
"properties": {
@@ -1091,6 +1102,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -11,6 +11,17 @@
],
"type": "string"
},
"AgentMessageModeration": {
"properties": {
"moderated": {
"type": "boolean"
}
},
"required": [
"moderated"
],
"type": "object"
},
"AsyncUserInputQuestion": {
"additionalProperties": false,
"properties": {
@@ -1091,6 +1102,17 @@
],
"default": null
},
"moderation": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageModeration"
},
{
"type": "null"
}
],
"default": null
},
"phase": {
"anyOf": [
{
@@ -0,0 +1,5 @@
// 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.
export type AgentMessageModeration = { moderated: boolean, };
@@ -10,6 +10,7 @@ import type { SleepItem } from "../SleepItem";
import type { WebSearchItem } from "../WebSearchItem";
import type { JsonValue } from "../serde_json/JsonValue";
import type { AgentMessageDelivery } from "./AgentMessageDelivery";
import type { AgentMessageModeration } from "./AgentMessageModeration";
import type { AsyncUserInputQuestion } from "./AsyncUserInputQuestion";
import type { CollabAgentState } from "./CollabAgentState";
import type { CollabAgentTool } from "./CollabAgentTool";
@@ -42,7 +43,7 @@ startedAtMs?: number | null,
/**
* Unix timestamp in milliseconds when the item completed, if recorded.
*/
completedAtMs?: number | null, } & ({ "type": "userMessage", id: string, clientId: string | null, content: Array<UserInput>, } | { "type": "hookPrompt", id: string, fragments: Array<HookPromptFragment>, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, delivery: AgentMessageDelivery | null, questions: Array<AsyncUserInputQuestion> | null, } | { "type": "functionCallOutput", id: string, name: string, namespace: string | null, output: FunctionCallOutputBody, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array<string>, content: Array<string>, } | { "type": "commandExecution", id: string,
completedAtMs?: number | null, } & ({ "type": "userMessage", id: string, clientId: string | null, content: Array<UserInput>, } | { "type": "hookPrompt", id: string, fragments: Array<HookPromptFragment>, } | { "type": "agentMessage", moderation: AgentMessageModeration | null, id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, delivery: AgentMessageDelivery | null, questions: Array<AsyncUserInputQuestion> | null, } | { "type": "functionCallOutput", id: string, name: string, namespace: string | null, output: FunctionCallOutputBody, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array<string>, content: Array<string>, } | { "type": "commandExecution", id: string,
/**
* Trusted first-party plugin id when this command resolves to one plugin script.
*/
@@ -20,6 +20,7 @@ export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermission
export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile";
export type { AgentMessageDelivery } from "./AgentMessageDelivery";
export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification";
export type { AgentMessageModeration } from "./AgentMessageModeration";
export type { AllowDenyRequirement } from "./AllowDenyRequirement";
export type { AnalyticsConfig } from "./AnalyticsConfig";
export type { AppBranding } from "./AppBranding";
@@ -493,6 +493,7 @@ mod tests {
fn assistant_completion_count_is_optional_envelope_metadata() {
let item = codex_protocol::items::TurnItem::AgentMessage(
codex_protocol::items::AgentMessageItem {
moderation: None,
id: "answer".into(),
content: vec![codex_protocol::items::AgentMessageContent::Text {
text: "Answer".into(),
@@ -5,6 +5,7 @@ use crate::protocol::item_builders::build_file_change_begin_item;
use crate::protocol::item_builders::build_file_change_end_item;
use crate::protocol::item_builders::build_item_from_guardian_event;
use crate::protocol::item_builders::review_output_text;
use crate::protocol::v2::AgentMessageModeration;
use crate::protocol::v2::CollabAgentState;
use crate::protocol::v2::CollabAgentTool;
use crate::protocol::v2::CollabAgentToolCallStatus;
@@ -626,8 +627,18 @@ impl ThreadHistoryBuilder {
return;
}
let id = self.next_item_id();
let legacy_id = self.next_item_id();
let id = payload
.moderation
.as_ref()
.map_or(legacy_id, |decision| decision.item_id.clone());
self.push_item_in_current_turn(ThreadItemPayload::AgentMessage {
moderation: payload
.moderation
.as_ref()
.map(|decision| AgentMessageModeration {
moderated: decision.moderated,
}),
id,
text: payload.message.clone(),
phase: payload.phase.clone(),
@@ -2164,6 +2175,7 @@ mod tests {
..Default::default()
}),
EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "Hi there".into(),
phase: None,
memory_citation: None,
@@ -2185,6 +2197,7 @@ mod tests {
..Default::default()
}),
EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "Reply two".into(),
phase: None,
memory_citation: None,
@@ -2226,6 +2239,7 @@ mod tests {
assert_eq!(
first.items[1].payload,
ThreadItemPayload::AgentMessage {
moderation: None,
id: "item-2".into(),
text: "Hi there".into(),
phase: None,
@@ -2261,6 +2275,7 @@ mod tests {
assert_eq!(
second.items[1].payload,
ThreadItemPayload::AgentMessage {
moderation: None,
id: "item-5".into(),
text: "Reply two".into(),
phase: None,
@@ -2977,6 +2992,7 @@ mod tests {
},
];
let events = vec![EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "Final reply".into(),
phase: Some(CoreMessagePhase::FinalAnswer),
memory_citation: None,
@@ -2993,6 +3009,7 @@ mod tests {
assert_eq!(
turns[0].items[0].payload,
ThreadItemPayload::AgentMessage {
moderation: None,
id: "item-1".into(),
text: "Final reply".into(),
phase: Some(CoreMessagePhase::FinalAnswer),
@@ -3123,6 +3140,7 @@ mod tests {
text: "first content".into(),
}),
EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "interlude".into(),
phase: None,
memory_citation: None,
@@ -3173,6 +3191,7 @@ mod tests {
..Default::default()
}),
EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "Working...".into(),
phase: None,
memory_citation: None,
@@ -3197,6 +3216,7 @@ mod tests {
..Default::default()
}),
EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "Second attempt complete.".into(),
phase: None,
memory_citation: None,
@@ -3229,6 +3249,7 @@ mod tests {
assert_eq!(
first_turn.items[1].payload,
ThreadItemPayload::AgentMessage {
moderation: None,
id: "item-2".into(),
text: "Working...".into(),
phase: None,
@@ -3255,6 +3276,7 @@ mod tests {
assert_eq!(
second_turn.items[1].payload,
ThreadItemPayload::AgentMessage {
moderation: None,
id: "item-4".into(),
text: "Second attempt complete.".into(),
phase: None,
@@ -3277,6 +3299,7 @@ mod tests {
..Default::default()
}),
EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "A1".into(),
phase: None,
memory_citation: None,
@@ -3292,6 +3315,7 @@ mod tests {
..Default::default()
}),
EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "A2".into(),
phase: None,
memory_citation: None,
@@ -3308,6 +3332,7 @@ mod tests {
..Default::default()
}),
EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "A3".into(),
phase: None,
memory_citation: None,
@@ -3339,6 +3364,7 @@ mod tests {
}],
},
&ThreadItemPayload::AgentMessage {
moderation: None,
id: "item-2".into(),
text: "A1".into(),
phase: None,
@@ -3360,6 +3386,7 @@ mod tests {
}],
},
&ThreadItemPayload::AgentMessage {
moderation: None,
id: "item-4".into(),
text: "A3".into(),
phase: None,
@@ -3383,6 +3410,7 @@ mod tests {
..Default::default()
}),
EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "A1".into(),
phase: None,
memory_citation: None,
@@ -3398,6 +3426,7 @@ mod tests {
..Default::default()
}),
EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "A2".into(),
phase: None,
memory_citation: None,
@@ -4399,6 +4428,7 @@ mod tests {
thread_id: ThreadId::default(),
turn_id: turn_id.clone(),
item: CoreTurnItem::AgentMessage(codex_protocol::items::AgentMessageItem {
moderation: None,
id: id.into(),
content: vec![codex_protocol::items::AgentMessageContent::Text {
text: text.into(),
@@ -4449,6 +4479,7 @@ mod tests {
}));
for message in ["draft", "answer"] {
builder.handle_event(&EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: message.into(),
phase: None,
memory_citation: None,
@@ -4758,6 +4789,7 @@ mod tests {
time_to_first_token_ms: None,
}),
EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "still in b".into(),
phase: None,
memory_citation: None,
@@ -4954,6 +4986,7 @@ mod tests {
duration_ms: None,
}),
EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "still in b".into(),
phase: None,
memory_citation: None,
@@ -5233,6 +5266,7 @@ mod tests {
..Default::default()
}),
EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "done".into(),
phase: None,
memory_citation: None,
@@ -129,6 +129,7 @@ fn projects_completed_canonical_turn_items() {
}],
});
let agent_item = TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "agent-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "done".to_string(),
@@ -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::AgentMessageModeration as CoreAgentMessageModeration;
pub use codex_protocol::items::AsyncUserInputQuestion;
use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool;
use codex_protocol::items::CollabAgentToolCallStatus as CoreCollabAgentToolCallStatus;
@@ -149,6 +150,21 @@ pub enum CommandAction {
},
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AgentMessageModeration {
pub moderated: bool,
}
impl From<CoreAgentMessageModeration> for AgentMessageModeration {
fn from(value: CoreAgentMessageModeration) -> Self {
Self {
moderated: value.moderated,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -313,6 +329,8 @@ pub enum ThreadItemPayload {
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
AgentMessage {
#[serde(default)]
moderation: Option<AgentMessageModeration>,
id: String,
text: String,
#[serde(default)]
@@ -958,6 +976,7 @@ impl From<CoreTurnItem> for ThreadItemPayload {
})
.collect::<String>();
Self::AgentMessage {
moderation: agent.moderation.map(Into::into),
id: agent.id,
text,
phase: agent.phase,
@@ -3260,6 +3260,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
);
let agent_item = TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "agent-1".to_string(),
content: vec![
AgentMessageContent::Text {
@@ -3275,9 +3276,17 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
questions: None,
});
let agent_payload = ThreadItemPayload::from(agent_item);
assert_eq!(
ThreadItemPayload::from(agent_item),
serde_json::to_value(&agent_payload)
.unwrap()
.get("moderation"),
Some(&serde_json::Value::Null),
);
assert_eq!(
agent_payload,
ThreadItemPayload::AgentMessage {
moderation: None,
id: "agent-1".to_string(),
text: "Hello world".to_string(),
phase: None,
@@ -3288,6 +3297,7 @@ 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 }),
id: "agent-2".to_string(),
content: vec![AgentMessageContent::Text {
text: "final".to_string(),
@@ -3309,6 +3319,7 @@ 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 }),
id: "agent-2".to_string(),
text: "final".to_string(),
phase: Some(MessagePhase::FinalAnswer),
@@ -3327,6 +3338,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
);
let async_item = ThreadItemPayload::from(TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "async-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "Which?".to_string(),
@@ -3343,6 +3355,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
serde_json::to_value(&async_item).unwrap(),
json!({
"type": "agentMessage", "id": "async-1", "text": "Which?", "phase": "final_answer",
"moderation": null,
"memoryCitation": null, "delivery": "async", "questions": [{"title": "Which?", "options": null}]
})
);
@@ -3508,6 +3508,7 @@ mod tests {
thread_id: conversation_id,
turn_id: event_turn_id.clone(),
item: CoreTurnItem::AgentMessage(CoreAgentMessageItem {
moderation: None,
id: "msg-1".to_string(),
content: vec![
CoreAgentMessageContent::Text {
@@ -3533,6 +3534,7 @@ mod tests {
thread_id: conversation_id,
turn_id: event_turn_id.clone(),
item: CoreTurnItem::AgentMessage(CoreAgentMessageItem {
moderation: None,
id: "msg-2".to_string(),
content: vec![CoreAgentMessageContent::Text {
text: " ".to_string(),
+1
View File
@@ -1176,6 +1176,7 @@ mod tests {
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "item-1".to_string(),
text: "Still working".to_string(),
phase: None,
@@ -72,6 +72,7 @@ mod tests {
fn redacts_mcp_success_result_and_removes_image_generation() {
let mut thread = test_thread(vec![
ThreadItemPayload::AgentMessage {
moderation: None,
id: "agent-1".to_string(),
text: "kept".to_string(),
phase: None,
@@ -132,6 +133,7 @@ mod tests {
assert_eq!(
thread.turns[0].items[0].payload,
ThreadItemPayload::AgentMessage {
moderation: None,
id: "agent-1".to_string(),
text: "kept".to_string(),
phase: None,
@@ -177,6 +177,7 @@ mod tests {
..Default::default()
})),
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "first answer".to_string(),
phase: None,
memory_citation: None,
@@ -2141,6 +2141,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
assert_eq!(
imported_items.next_back(),
Some(&ThreadItemPayload::AgentMessage {
moderation: None,
id: "item-4".into(),
text: "<EXTERNAL SESSION IMPORTED>".into(),
phase: None,
@@ -708,6 +708,7 @@ async fn thread_search_occurrences_reads_paginated_projection() -> Result<()> {
thread_id,
"turn-1",
CoreTurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "commentary-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "commentary needle".to_string(),
@@ -722,6 +723,7 @@ async fn thread_search_occurrences_reads_paginated_projection() -> Result<()> {
thread_id,
"turn-1",
CoreTurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "partial-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "stable partial answer".to_string(),
@@ -736,6 +738,7 @@ async fn thread_search_occurrences_reads_paginated_projection() -> Result<()> {
thread_id,
"turn-1",
CoreTurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "final-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "😀 **Final** \nneedle".to_string(),
@@ -2026,6 +2029,7 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
thread_id,
"turn-1",
CoreTurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "agent-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "first".to_string(),
@@ -2082,6 +2086,7 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
content: Vec::new(),
},
ThreadItemPayload::AgentMessage {
moderation: None,
id: "agent-1".to_string(),
text: "first".to_string(),
phase: None,
@@ -2286,6 +2291,7 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
content: Vec::new(),
},
ThreadItemPayload::AgentMessage {
moderation: None,
id: "agent-1".to_string(),
text: "first".to_string(),
phase: None,
@@ -2532,6 +2538,7 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
output_deferred: false,
item: codex_app_server_protocol::ThreadItem {
payload: ThreadItemPayload::AgentMessage {
moderation: None,
id: "agent-1".to_string(),
text: "first".to_string(),
phase: None,
@@ -2756,6 +2763,7 @@ fn append_agent_message(path: &Path, timestamp: &str, text: &str) -> anyhow::Res
"timestamp": timestamp,
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: text.to_string(),
phase: None,
memory_citation: None,
@@ -4564,6 +4564,7 @@ async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn()
"timestamp": meta_rfc3339,
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "Still running".to_string(),
phase: None,
memory_citation: None,
@@ -4654,6 +4655,7 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re
"timestamp": meta_rfc3339,
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "Interrupted after usage".to_string(),
phase: None,
memory_citation: None,
@@ -4976,6 +4978,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is
"timestamp": meta_rfc3339,
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "Still running".to_string(),
phase: None,
memory_citation: None,
+1
View File
@@ -170,6 +170,7 @@ fn parse_agent_message(
.map(str::to_string)
.unwrap_or_else(|| Uuid::new_v4().to_string());
AgentMessageItem {
moderation: None,
id,
content,
phase,
@@ -225,6 +225,7 @@ fn promotes_backing_agent_artifacts_once_without_a_client_request() {
);
let completed = completed_item(TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "message-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "[analysis] ::codex-realtime-inline{}\nVisible explanation".to_string(),
@@ -304,6 +305,7 @@ fn promotes_distinct_visualizations_once_and_ignores_markdown_fences() {
);
let completed = completed_item(TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "message-1".to_string(),
content: vec![AgentMessageContent::Text {
text: text.to_string(),
+5 -3
View File
@@ -13311,9 +13311,11 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() {
cancellation_token: CancellationToken::new(),
};
let output = handle_output_item_done(&mut ctx, item, /*previously_active_item*/ None)
.await
.expect("tool call should be handled");
let output = handle_output_item_done(
&mut ctx, item, /*previously_active_item*/ None, /*moderation*/ None,
)
.await
.expect("tool call should be handled");
assert!(output.needs_follow_up);
assert!(output.tool_future.is_some());
+27 -9
View File
@@ -2731,6 +2731,7 @@ async fn emit_agent_message_in_plan_mode(
.remove(&agent_message_id)
.unwrap_or_else(|| {
TurnItem::AgentMessage(codex_protocol::items::AgentMessageItem {
moderation: None,
id: agent_message_id.clone(),
content: Vec::new(),
phase: None,
@@ -2785,6 +2786,7 @@ async fn emit_turn_item_in_plan_mode(
}
/// Handle a completed assistant response item in plan mode, returning true if handled.
#[allow(clippy::too_many_arguments)]
async fn handle_assistant_item_done_in_plan_mode(
sess: &Session,
step_context: &StepContext,
@@ -2793,6 +2795,7 @@ async fn handle_assistant_item_done_in_plan_mode(
state: &mut PlanModeStreamState,
previously_active_item: Option<&TurnItem>,
last_agent_message: &mut Option<String>,
moderation: Option<&codex_protocol::protocol::TargetedAnswerModeration>,
) -> bool {
let turn_context = &step_context.turn;
if let ResponseItem::Message { role, .. } = item
@@ -2801,7 +2804,7 @@ async fn handle_assistant_item_done_in_plan_mode(
maybe_complete_plan_item_from_message(sess, turn_context, state, item).await;
let mut finalized_facts = None;
if let Some(finalized_turn_item) = finalize_non_tool_response_item(
if let Some(mut finalized_turn_item) = finalize_non_tool_response_item(
sess,
TurnItemContributorPolicy::Run(turn_store),
item,
@@ -2809,6 +2812,10 @@ async fn handle_assistant_item_done_in_plan_mode(
)
.await
{
crate::stream_events_utils::apply_answer_moderation(
&mut finalized_turn_item.turn_item,
moderation,
);
finalized_facts = Some(finalized_turn_item.facts.clone());
emit_turn_item_in_plan_mode(
sess,
@@ -2967,6 +2974,7 @@ async fn try_run_sampling_request(
}
let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling();
let mut response_id_for_assessment = None;
let mut answer_moderation: Option<codex_protocol::protocol::TargetedAnswerModeration> = None;
let mut pending_assessment: Option<codex_api::GuardianAssessment> = None;
let mut response_completed = false;
let mut request_token_usage: Option<codex_protocol::protocol::TokenUsage> = None;
@@ -3171,6 +3179,7 @@ async fn try_run_sampling_request(
match event {
ResponseEvent::Created { response_id } => {
answer_moderation = None;
response_id_for_assessment.clone_from(&response_id);
if let Some(response_id) = response_id {
turn_context
@@ -3255,6 +3264,7 @@ async fn try_run_sampling_request(
state,
previously_streamed_item.as_ref(),
&mut last_agent_message,
answer_moderation.as_ref(),
)
.await
{
@@ -3306,14 +3316,18 @@ async fn try_run_sampling_request(
| ResponseItem::Other => false,
};
let output_result =
match handle_output_item_done(&mut ctx, item, previously_streamed_item)
.instrument(handle_responses)
.await
{
Ok(output_result) => output_result,
Err(err) => break Err(err),
};
let output_result = match handle_output_item_done(
&mut ctx,
item,
previously_streamed_item,
answer_moderation.as_ref(),
)
.instrument(handle_responses)
.await
{
Ok(output_result) => output_result,
Err(err) => break Err(err),
};
if let Some(tool_future) = output_result.tool_future {
in_flight.push_back(tool_future);
}
@@ -3468,6 +3482,10 @@ async fn try_run_sampling_request(
}
}
ResponseEvent::TurnModerationMetadata(metadata) => {
answer_moderation = metadata
.metadata
.get("final_answer_moderation")
.and_then(|marker| serde::Deserialize::deserialize(marker).ok());
sess.emit_turn_moderation_metadata(&turn_context, metadata)
.await;
}
+1
View File
@@ -84,6 +84,7 @@ async fn plan_mode_uses_contributed_turn_item_for_last_agent_message() {
&mut state,
/*previously_active_item*/ None,
&mut last_agent_message,
/*moderation*/ None,
)
.await;
+17 -1
View File
@@ -332,11 +332,26 @@ pub(crate) async fn finalize_non_tool_response_item(
})
}
pub(crate) fn apply_answer_moderation(
item: &mut TurnItem,
decision: Option<&codex_protocol::protocol::TargetedAnswerModeration>,
) {
if let (TurnItem::AgentMessage(answer), Some(decision)) = (item, decision)
&& answer.phase == Some(codex_protocol::models::MessagePhase::FinalAnswer)
&& answer.id == decision.item_id
{
answer.moderation = Some(codex_protocol::items::AgentMessageModeration {
moderated: decision.moderated,
});
}
}
#[instrument(level = "trace", skip_all)]
pub(crate) async fn handle_output_item_done(
ctx: &mut HandleOutputCtx,
item: ResponseItem,
previously_active_item: Option<TurnItem>,
moderation: Option<&codex_protocol::protocol::TargetedAnswerModeration>,
) -> Result<OutputItemResult> {
let mut output = OutputItemResult::default();
let plan_mode = ctx.step_context.turn.mode() == ModeKind::Plan;
@@ -404,7 +419,7 @@ pub(crate) async fn handle_output_item_done(
let finalized_facts = finalized_turn_item
.as_ref()
.map(|finalized| finalized.facts.clone());
if let Some(finalized_turn_item) = finalized_turn_item {
if let Some(mut finalized_turn_item) = finalized_turn_item {
if previously_active_item.is_none() {
ctx.sess
.emit_turn_item_started(
@@ -414,6 +429,7 @@ pub(crate) async fn handle_output_item_done(
.await;
}
apply_answer_moderation(&mut finalized_turn_item.turn_item, moderation);
ctx.sess
.emit_turn_item_completed_with_metadata(
&ctx.step_context.turn,
+17 -11
View File
@@ -329,9 +329,11 @@ async fn handle_output_item_done_returns_contributed_last_agent_message() {
let mut ctx = output_context(Arc::new(session), Arc::new(turn_context)).await;
let item = assistant_output_text("original assistant text");
let output = handle_output_item_done(&mut ctx, item, /*previously_active_item*/ None)
.await
.expect("assistant message should complete");
let output = handle_output_item_done(
&mut ctx, item, /*previously_active_item*/ None, /*moderation*/ None,
)
.await
.expect("assistant message should complete");
assert_eq!(
output.last_agent_message.as_deref(),
@@ -362,9 +364,11 @@ async fn direct_results_keep_their_own_records_when_call_ids_repeat() {
"arguments": arguments.to_string(),
}))
.expect("direct input");
let result = handle_output_item_done(&mut ctx, call, /*previously_active_item*/ None)
.await
.expect("direct call should be handled");
let result = handle_output_item_done(
&mut ctx, call, /*previously_active_item*/ None, /*moderation*/ None,
)
.await
.expect("direct call should be handled");
assert!(result.needs_follow_up);
pending.push((
arguments,
@@ -421,11 +425,13 @@ async fn direct_results_keep_their_own_records_when_call_ids_repeat() {
}))
.expect("direct input");
pending.push(
handle_output_item_done(&mut ctx, call, /*previously_active_item*/ None)
.await
.expect("direct call should be handled")
.tool_future
.expect("dispatched direct call"),
handle_output_item_done(
&mut ctx, call, /*previously_active_item*/ None, /*moderation*/ None,
)
.await
.expect("direct call should be handled")
.tool_future
.expect("dispatched direct call"),
);
}
let current_config = session.get_config().await;
@@ -3771,6 +3771,7 @@ fn completed_legacy_event_history_is_not_mid_turn() {
..Default::default()
})),
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "done".to_string(),
phase: None,
memory_citation: None,
@@ -129,6 +129,7 @@ impl ToolExecutor<ToolInvocation> for RequestUserInputAsyncHandler {
}
let item = TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: call_id,
content: vec![AgentMessageContent::Text {
text: messages.join("\n\n"),
@@ -82,6 +82,7 @@ impl ToolExecutor<ToolInvocation> for SendMessageToUserAsyncHandler {
}
let item = TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: call_id,
content: vec![AgentMessageContent::Text {
text: message.to_string(),
+2
View File
@@ -66,6 +66,7 @@ async fn turn_timing_state_records_ttfm_independently_of_ttft() {
assert!(
state
.record_ttfm_for_turn_item(&TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "msg-1".to_string(),
content: Vec::new(),
phase: None,
@@ -79,6 +80,7 @@ async fn turn_timing_state_records_ttfm_independently_of_ttft() {
assert_eq!(
state
.record_ttfm_for_turn_item(&TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "msg-2".to_string(),
content: Vec::new(),
phase: None,
@@ -457,3 +457,50 @@ async fn model_request_gates_websocket_output_but_not_warmup() -> Result<()> {
server.shutdown().await;
Ok(())
}
/// The same final answer carries the targeted public flag on completion and in legacy history.
#[tokio::test]
async fn final_answer_carries_its_moderation_decision() -> Result<()> {
use core_test_support::wait_for_event_match;
let server = responses::start_mock_server().await;
let mut answer = responses::ev_assistant_message("msg-42", "Answer");
answer["item"]["phase"] = json!("final_answer");
responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
json!({"type":"response.metadata","metadata":{"openai_chatgpt_moderation_metadata":{
"final_answer_moderation":{"item_id":"msg-42","moderated":true},
"generation":{"private":"classifier details"}
}}}),
answer,
responses::ev_completed("resp-1"),
]),
)
.await;
let test = test_codex().build_with_auto_env(&server).await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "test".into(),
text_elements: Vec::new(),
}]))
.await?;
let item = wait_for_event_match(&test.codex, |event| match event {
EventMsg::ItemCompleted(done) if done.item.id() == "msg-42" => Some(done.item.clone()),
_ => None,
})
.await;
let codex_protocol::items::TurnItem::AgentMessage(message) = item else {
panic!("expected assistant answer");
};
let public = serde_json::to_value(&message)?;
assert_eq!(public["moderation"], json!({"moderated": true}));
let [EventMsg::AgentMessage(legacy)] = &message.as_legacy_events()[..] else {
panic!("expected one legacy answer");
};
assert_eq!(
serde_json::to_value(&legacy.moderation)?,
json!({"item_id":"msg-42","moderated":true})
);
Ok(())
}
@@ -367,6 +367,7 @@ async fn freeform_async_message_emits_an_item_without_ending_the_turn(
})
.await;
let expected = serde_json::to_value(TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: CALL_ID.to_string(),
content: vec![AgentMessageContent::Text {
text: MESSAGE.to_string(),
@@ -555,6 +556,7 @@ async fn request_user_input_async_emits_item_and_does_not_end_the_turn(
assert_eq!(
serde_json::to_value(&started)?,
serde_json::to_value(AgentMessageItem {
moderation: None,
id: CALL_ID.to_string(),
content: vec![AgentMessageContent::Text {
text: MESSAGE.to_string(),
@@ -354,6 +354,7 @@ async fn config_summary_entries_include_runtime_workspace_roots() {
fn final_message_from_turn_items_uses_latest_agent_message() {
let message = final_message_from_turn_items(&[
ThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".to_string(),
text: "first".to_string(),
phase: None,
@@ -366,6 +367,7 @@ fn final_message_from_turn_items_uses_latest_agent_message() {
text: "plan".to_string(),
},
ThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-2".to_string(),
text: "second".to_string(),
phase: None,
@@ -428,6 +430,7 @@ fn turn_completed_recovers_final_message_from_turn_items() {
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".to_string(),
text: "final answer".to_string(),
phase: None,
@@ -480,6 +483,7 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() {
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".to_string(),
text: "final answer".to_string(),
phase: None,
@@ -16,6 +16,7 @@ fn failed_turn_does_not_overwrite_output_last_message_file() {
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".to_string(),
text: "partial answer".to_string(),
phase: None,
+2
View File
@@ -391,6 +391,7 @@ fn turn_items_for_thread_returns_matching_turn_items() {
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::untimed(
AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
@@ -426,6 +427,7 @@ fn turn_items_for_thread_returns_matching_turn_items() {
turn_items_for_thread(&thread, "turn-1"),
Some(vec![ThreadItem::untimed(
AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
@@ -312,6 +312,7 @@ fn unsupported_items_do_not_consume_synthetic_ids() {
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "message-1".to_string(),
text: "hello".to_string(),
phase: None,
@@ -1014,6 +1015,7 @@ fn agent_message_item_updates_final_message() {
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
@@ -1052,6 +1054,7 @@ fn agent_message_item_started_is_ignored() {
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
@@ -1408,6 +1411,7 @@ fn turn_completion_recovers_final_message_from_turn_items() {
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".to_string(),
text: "final answer".to_string(),
phase: None,
@@ -1545,6 +1549,7 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() {
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-stale".to_string(),
text: "stale answer".to_string(),
phase: None,
@@ -1565,6 +1570,7 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() {
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".to_string(),
text: "final answer".to_string(),
phase: None,
@@ -1601,6 +1607,7 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty()
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-streamed".to_string(),
text: "streamed answer".to_string(),
phase: None,
@@ -1651,6 +1658,7 @@ fn failed_turn_clears_stale_final_message() {
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".to_string(),
text: "partial answer".to_string(),
phase: None,
+1
View File
@@ -62,6 +62,7 @@ fn goal_accounting_ignores_plan_mode_turns() {
#[test]
fn empty_continuations_require_three_turns_without_activity_or_goal_changes() {
let empty_final = TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "empty".into(),
content: vec![AgentMessageContent::Text { text: " \n".into() }],
phase: Some(MessagePhase::FinalAnswer),
@@ -134,6 +134,7 @@ pub(super) fn rollout_items_from_messages(messages: Vec<ConversationMessage>) ->
last_model_visible_tokens = approx_tokens_from_byte_count_i64(response_item_bytes);
items.push(RolloutItem::EventMsg(EventMsg::AgentMessage(
AgentMessageEvent {
moderation: None,
message: message.text.clone(),
phase: None,
memory_citation: None,
@@ -157,6 +158,7 @@ pub(super) fn rollout_items_from_messages(messages: Vec<ConversationMessage>) ->
fn external_session_imported_marker_item() -> RolloutItem {
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: EXTERNAL_SESSION_IMPORTED_MARKER.to_string(),
phase: None,
memory_citation: None,
@@ -261,6 +263,7 @@ mod tests {
assert_eq!(
turns[1].item_payloads().next_back(),
Some(&ThreadItemPayload::AgentMessage {
moderation: None,
id: "item-4".into(),
text: EXTERNAL_SESSION_IMPORTED_MARKER.into(),
phase: None,
@@ -295,6 +298,7 @@ mod tests {
assert_eq!(
turns[0].item_payloads().next_back(),
Some(&ThreadItemPayload::AgentMessage {
moderation: None,
id: "item-3".into(),
text: EXTERNAL_SESSION_IMPORTED_MARKER.into(),
phase: None,
+8
View File
@@ -151,6 +151,11 @@ pub struct AsyncUserInputQuestion {
pub options: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS, JsonSchema)]
pub struct AgentMessageModeration {
pub moderated: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
/// Assistant-authored message payload used in turn-item streams.
///
@@ -158,6 +163,9 @@ pub struct AsyncUserInputQuestion {
/// should use it when present, but retain legacy completion semantics when it
/// is `None`.
pub struct AgentMessageItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub moderation: Option<AgentMessageModeration>,
pub id: String,
pub content: Vec<AgentMessageContent>,
/// Optional phase metadata carried through from `ResponseItem::Message`.
+23 -11
View File
@@ -126,18 +126,30 @@ impl UserMessageItem {
impl AgentMessageItem {
pub fn as_legacy_events(&self) -> Vec<EventMsg> {
self.content
.iter()
.map(|c| match c {
AgentMessageContent::Text { text } => EventMsg::AgentMessage(AgentMessageEvent {
message: text.clone(),
phase: self.phase.clone(),
memory_citation: self.memory_citation.clone(),
delivery: self.delivery,
questions: self.questions.clone(),
}),
let texts = self.content.iter().map(|part| match part {
AgentMessageContent::Text { text } => text.as_str(),
});
let event = |message, moderation| {
EventMsg::AgentMessage(AgentMessageEvent {
message,
moderation,
phase: self.phase.clone(),
memory_citation: self.memory_citation.clone(),
delivery: self.delivery,
questions: self.questions.clone(),
})
.collect()
};
if let Some(decision) = self.moderation {
// The canonical answer has one ID even when the model supplied multiple text parts.
return vec![event(
texts.collect(),
Some(crate::protocol::TargetedAnswerModeration {
item_id: self.id.clone(),
moderated: decision.moderated,
}),
)];
}
texts.map(|text| event(text.to_owned(), None)).collect()
}
}
+10
View File
@@ -2392,6 +2392,12 @@ pub struct ModelVerificationEvent {
pub verifications: Vec<ModelVerification>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct TargetedAnswerModeration {
pub item_id: String,
pub moderated: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct TurnModerationMetadataEvent {
pub metadata: Value,
@@ -2790,6 +2796,10 @@ impl fmt::Display for FinalOutput {
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct AgentMessageEvent {
/// Legacy history assigns synthetic item IDs. Preserve the native ID for a moderated answer.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub moderation: Option<TargetedAnswerModeration>,
pub message: String,
#[serde(default)]
pub phase: Option<MessagePhase>,
+4
View File
@@ -66,6 +66,7 @@ fn paginated_session_meta_item(thread_id: ThreadId, cwd: &Path) -> RolloutItem {
fn agent_message_item(message: &str) -> RolloutItem {
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: message.to_string(),
phase: None,
memory_citation: None,
@@ -726,6 +727,7 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result<
recorder
.record_canonical_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage(
AgentMessageEvent {
moderation: None,
message: "buffered-event".to_string(),
phase: None,
memory_citation: None,
@@ -993,6 +995,7 @@ async fn persist_reports_filesystem_error_and_retries_buffered_items() -> std::i
recorder
.record_canonical_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage(
AgentMessageEvent {
moderation: None,
message: "buffered-before-persist".to_string(),
phase: None,
memory_citation: None,
@@ -1046,6 +1049,7 @@ async fn writer_state_retries_write_error_before_reporting_flush_success() -> st
};
state.add_items(vec![RolloutItem::EventMsg(EventMsg::AgentMessage(
AgentMessageEvent {
moderation: None,
message: "queued-after-writer-error".to_string(),
phase: None,
memory_citation: None,
+1
View File
@@ -1256,6 +1256,7 @@ mod tests {
live_thread
.append_items(&[
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: "commentary".to_string(),
phase: Some(MessagePhase::Commentary),
memory_citation: None,
@@ -158,7 +158,15 @@ pub(super) fn completed_item(
let result = match event {
EventMsg::AgentMessage(event) if !event.message.is_empty() => Some((
TurnItem::AgentMessage(AgentMessageItem {
id: next_item_id()?,
moderation: event.moderation.as_ref().map(|decision| {
codex_protocol::items::AgentMessageModeration {
moderated: decision.moderated,
}
}),
id: event
.moderation
.as_ref()
.map_or(next_item_id()?, |decision| decision.item_id.clone()),
content: vec![AgentMessageContent::Text {
text: event.message.clone(),
}],
@@ -172,6 +172,7 @@ fn user_message(text: &str) -> RolloutItem {
fn agent_message(text: &str) -> RolloutItem {
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: text.to_string(),
phase: None,
memory_citation: None,
@@ -236,6 +236,7 @@ async fn split_homes_support_backfill_listing_and_paginated_history() {
thread_id,
"turn-1",
TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "agent-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "done".to_string(),
@@ -330,6 +331,7 @@ async fn paginated_live_append_materializes_turn_items_and_state() {
thread_id,
"turn-1",
TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "agent-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "done".to_string(),
@@ -646,6 +648,7 @@ async fn paginated_realtime_items_materialize_separately_in_rollout_order() {
thread_id,
"turn-1",
TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "agent-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "Result".to_string(),
@@ -3183,6 +3186,7 @@ fn completed_item(thread_id: ThreadId, turn_id: &str, item: TurnItem) -> Rollout
fn agent_message(id: &str, phase: MessagePhase) -> TurnItem {
TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: id.to_string(),
content: vec![AgentMessageContent::Text {
text: id.to_string(),
@@ -196,6 +196,7 @@ async fn every_page_split_folds_reasoning_before_answer_and_completion_boundarie
let mut items = vec![call("call", kind, "completed"), reasoning("detail")];
if let Some(text) = answer {
items.push(ThreadItemPayload::AgentMessage {
moderation: None,
id: "answer".to_owned(),
text: text.to_owned(),
phase: None,
@@ -38,6 +38,7 @@ fn agent_status_uses_bounded_buffered_activity() {
"thread-child".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "message-1".to_string(),
text: "Finished checking the focused TUI tests.".to_string(),
phase: None,
@@ -947,6 +947,7 @@ async fn agents_overview_details_render_markdown() {
thread.id.clone(),
"turn".into(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "answer".into(),
text: message.into(),
phase: None,
@@ -1100,6 +1101,7 @@ async fn agents_overview_reasoning_uses_existing_events_and_expires_with_attachm
parent.id.clone(),
"previous-turn".into(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "answer".into(),
text: "Previous answer".into(),
phase: None,
+3
View File
@@ -4316,6 +4316,7 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
app.enqueue_thread_notification(parent, delta).await?;
let streaming = side_frame(&mut app, &mut tui, size)?;
let item = ThreadItemPayload::AgentMessage {
moderation: None,
id: "reply".into(),
text: "Already visible while unfocused.".into(),
phase: None,
@@ -5969,6 +5970,7 @@ async fn active_thread_file_change_approval_recovers_buffered_changes() {
thread_id.to_string(),
"turn-active-approval".to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "agent-1".to_string(),
text: "streaming".to_string(),
phase: None,
@@ -10702,6 +10704,7 @@ async fn replay_thread_snapshot_replays_turn_history_in_order() {
}],
}),
ThreadItem::untimed(ThreadItemPayload::AgentMessage {
moderation: None,
id: "assistant-2".to_string(),
text: "done".to_string(),
phase: None,
@@ -23,6 +23,7 @@ fn completed(thread: &str) -> ServerNotification {
thread.into(),
"turn".into(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "answer".into(),
text: "Final **answer**".into(),
phase: None,
@@ -243,6 +244,7 @@ fn evicted_voice_delegation_marker_still_suppresses_private_replay() {
"thread".into(),
"voice-turn".into(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "reasoning".into(),
text: "[ANALYSIS] private".into(),
phase: None,
@@ -378,6 +380,7 @@ fn saved_voice_turn_suppresses_reasoning_without_hiding_typed_reasoning() {
#[test]
fn snapshot_keeps_typed_output_before_voice_handoff_in_the_same_turn() {
let commentary = |id: &str, text: &str| ThreadItemPayload::AgentMessage {
moderation: None,
id: id.into(),
text: text.into(),
phase: Some(codex_protocol::models::MessagePhase::Commentary),
@@ -451,6 +454,7 @@ fn snapshot_keeps_typed_output_before_voice_handoff_in_the_same_turn() {
#[test]
fn snapshot_keeps_typed_item_completed_after_voice_handoff() {
let typed = ThreadItemPayload::AgentMessage {
moderation: None,
id: "typed".into(),
text: "Typed commentary completed late".into(),
phase: Some(codex_protocol::models::MessagePhase::Commentary),
@@ -459,6 +463,7 @@ fn snapshot_keeps_typed_item_completed_after_voice_handoff() {
delivery: None,
};
let private = ThreadItemPayload::AgentMessage {
moderation: None,
id: "private".into(),
text: "Voice-private commentary".into(),
phase: Some(codex_protocol::models::MessagePhase::Commentary),
@@ -632,6 +637,7 @@ async fn voice_handoff_replay_keeps_public_reasoning_and_hides_private_reasoning
thread_id.to_string(),
"voice-turn".into(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "private-commentary".into(),
text: "[COMMENTARY] private after switch".into(),
phase: None,
@@ -75,6 +75,7 @@ pub(super) async fn completed_history_app(
}],
}),
TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: format!("answer-{index}"),
content: vec![AgentMessageContent::Text {
text: format!("{name} answer"),
@@ -341,6 +341,7 @@ async fn active_patch_approval_preserves_deferred_startup_protection() -> Result
thread_id.to_string(),
TURN_ID.to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "agent-1".to_string(),
text: "streaming".to_string(),
phase: None,
@@ -48,6 +48,7 @@ fn voice_snapshot_directory_keeps_header_width_for_windows_paths() {
fn test_agent_message(id: &str, text: &str) -> ThreadItemPayload {
ThreadItemPayload::AgentMessage {
moderation: None,
id: id.into(),
text: text.into(),
phase: Some(MessagePhase::FinalAnswer),
@@ -743,6 +744,7 @@ async fn retained_caption_consumes_only_one_matching_answer_fallback_on_reattach
.into(),
);
let answer = |id: &str, text: &str| ThreadItemPayload::AgentMessage {
moderation: None,
id: id.into(),
text: text.into(),
phase: Some(MessagePhase::FinalAnswer),
@@ -3072,6 +3072,7 @@ async fn older_pagination_reconciles_review_prompts_across_page_boundaries() ->
];
items.extend((0..97).map(|index| {
TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: format!("review-output-{index}"),
content: vec![AgentMessageContent::Text {
text: format!("review output {index}"),
@@ -3270,6 +3271,7 @@ async fn transcript_alt_beginning_loads_every_older_history_page() -> Result<()>
thread_id,
turn_id: "multi-page-turn".to_string(),
item: TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: format!("history-item-{index}"),
content: vec![AgentMessageContent::Text {
text: format!("history output {index}"),
@@ -3687,6 +3689,7 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri
thread_id,
turn_id: "scrollback-pagination-turn".to_string(),
item: TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: format!("scrollback-item-{index}"),
content: vec![AgentMessageContent::Text {
text: format!("scrollback output {index}"),
@@ -5947,6 +5950,7 @@ async fn command_center_read_only_open_requests_and_failure_preservation() -> Re
thread_id,
turn_id: format!("saved-turn-{index}"),
item: TurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: format!("saved-answer-{index}"),
content: vec![AgentMessageContent::Text {
text: "Saved final answer".to_string(),
@@ -95,6 +95,7 @@ async fn owned_startup_preserves_loading_until_resume_replay_is_applied() -> Res
for items in [
Vec::new(),
vec![ThreadItemPayload::AgentMessage {
moderation: None,
id: "answer".into(),
text: "Retained **answer** after resume.".into(),
phase: None,
@@ -255,6 +255,7 @@ async fn inactive_thread_user_verification_preserves_foreground_stream() -> Resu
foreground_thread_id.to_string(),
"turn-foreground".to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "message-foreground".to_string(),
text: "The foreground answer continues.".to_string(),
phase: None,
+1
View File
@@ -1051,6 +1051,7 @@ mod tests {
store.push_notification(serde_json::from_value(expected.clone()).unwrap());
store.rebase_buffer_after_session_refresh();
expected["params"]["item"]["text"] = serde_json::json!("");
expected["params"]["item"]["moderation"] = serde_json::Value::Null;
let ThreadBufferedEvent::Notification(actual) = &store.snapshot().events[0] else {
panic!("missing live question");
};
@@ -855,6 +855,7 @@ fn title_user_message(id: &str, text: &str) -> ThreadItemPayload {
fn title_agent_message(id: &str, text: &str, phase: Option<MessagePhase>) -> ThreadItemPayload {
ThreadItemPayload::AgentMessage {
moderation: None,
id: id.to_string(),
text: text.to_string(),
phase,
+1
View File
@@ -4834,6 +4834,7 @@ mod tests {
),
ThreadItem::untimed(
codex_app_server_protocol::ThreadItemPayload::AgentMessage {
moderation: None,
id: "assistant-1".to_string(),
text: "assistant reply".to_string(),
phase: None,
@@ -83,6 +83,7 @@ fn user_item(text: &str) -> ThreadItemPayload {
fn agent_item(item_id: &str, text: &str, phase: Option<MessagePhase>) -> ThreadItemPayload {
ThreadItemPayload::AgentMessage {
moderation: None,
id: item_id.to_string(),
text: text.to_string(),
phase,
@@ -181,6 +181,7 @@ async fn delegated_async_question_stays_local_and_expires_when_its_turn_ends() {
user_item("<realtime_delegation><input>choose</input></realtime_delegation>"),
);
let answer = ThreadItemPayload::AgentMessage {
moderation: None,
id: "question-answer".into(),
text: "Which option?".into(),
phase: Some(MessagePhase::FinalAnswer),
+2
View File
@@ -306,6 +306,7 @@ impl ChatWidget {
if self.complete_realtime_delegated_agent_item(
&turn_id,
&ThreadItemPayload::AgentMessage {
moderation: None,
id: id.clone(),
text: text.clone(),
phase: phase.clone(),
@@ -319,6 +320,7 @@ impl ChatWidget {
}
self.on_agent_message_item_completed(
AgentMessageItem {
moderation: None,
id,
content: vec![AgentMessageContent::Text { text }],
phase,
@@ -964,6 +964,7 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item(
assert_eq!(status.header(), "Working");
let item = AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".to_string(),
text: "Yes. What do you need?".to_string(),
phase: Some(MessagePhase::FinalAnswer),
@@ -1773,6 +1774,7 @@ async fn live_app_server_turn_completion_repairs_dropped_message_deltas() {
);
completed_turn.items_view = codex_app_server_protocol::TurnItemsView::Summary;
completed_turn.items = vec![ThreadItem::untimed(AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".to_string(),
text: concat!(
"The transport kept this.\nAnd dropped this.\n\n",
@@ -14,6 +14,7 @@ fn completed_turn(duration_ms: Option<i64>, completed_at: Option<i64>) -> AppSer
completed_at,
items: vec![ThreadItem::untimed(
AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "answer-1".to_string(),
text: "The change is ready.".to_string(),
phase: Some(MessagePhase::FinalAnswer),
@@ -926,6 +926,7 @@ pub(super) fn replay_agent_message(
) {
chat.replay_thread_item(
AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: item_id.to_string(),
text: text.into(),
phase: Some(MessagePhase::FinalAnswer),
@@ -1082,6 +1083,7 @@ pub(super) fn complete_assistant_message(
chat.thread_id.map(|id| id.to_string()).unwrap_or_default(),
"turn-1".to_string(),
AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: item_id.to_string(),
text: text.to_string(),
phase,
@@ -460,6 +460,7 @@ async fn replayed_nested_review_prompts_do_not_render_or_seed_composer_history()
ThreadItem::untimed(user_message("review-prompt-1", review_prompt)),
ThreadItem::untimed(user_message("review-prompt-2", review_prompt)),
ThreadItem::untimed(AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "review-result".to_string(),
text: "review result is retained".to_string(),
phase: Some(MessagePhase::FinalAnswer),
@@ -1038,6 +1039,7 @@ async fn prompt_edit_stops_streaming_without_submitting_queued_input() {
let retained_turn = AppServerTurn {
items: vec![
ThreadItem::untimed(AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "retained-response".to_string(),
text: "retained response".to_string(),
phase: Some(MessagePhase::FinalAnswer),
@@ -1055,6 +1057,7 @@ async fn prompt_edit_stops_streaming_without_submitting_queued_input() {
}],
}),
ThreadItem::untimed(AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "private-commentary".to_string(),
text: "hidden voice commentary".to_string(),
phase: Some(MessagePhase::Commentary),
@@ -805,6 +805,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() {
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::untimed(
AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-plan".to_string(),
text: "Plan details".to_string(),
phase: Some(MessagePhase::FinalAnswer),
@@ -848,6 +849,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::untimed(
AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-plan-replay".to_string(),
text: "Plan details".to_string(),
phase: Some(MessagePhase::FinalAnswer),
@@ -8,6 +8,7 @@ use pretty_assertions::assert_eq;
async fn live_async_question_notifies_once_and_takes_priority_over_turn_completion() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let item = AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "question".into(),
text: String::new(),
phase: None,
@@ -648,6 +648,7 @@ fn open_questions(chat: &mut ChatWidget, options: Option<Vec<String>>) {
chat.thread_id = Some(ThreadId::new());
chat.on_agent_message_item_completed(
AgentMessageItem {
moderation: None,
id: "review".into(),
content: Vec::new(),
phase: None,
@@ -801,6 +802,7 @@ async fn question_drafts_survive_navigation_and_snapshot_replay() {
let saved = chat.capture_thread_input_state();
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
let item = AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "buffered".into(),
text: String::new(),
phase: None,
@@ -1067,6 +1069,7 @@ async fn async_question_markdown_matches_loaded_history() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let title = "Keep `--verbose` for **failed** runs?";
let item = AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "ask".into(),
text: format!("{title}\n- Only for **CI**\n\nSee the [guide](https://example.test/guide)?"),
phase: None,
@@ -1636,6 +1636,7 @@ async fn slash_copy_picker_uses_completed_commentary_during_active_turn() {
current_thread_id,
"active".to_string(),
AppServerThreadItemPayload::AgentMessage {
moderation: None,
id: "active-commentary".to_string(),
text: commentary.to_string(),
phase: Some(MessagePhase::Commentary),
@@ -41,6 +41,7 @@ async fn blocked_copy_allows_overlay_exit_rejects_backlog_and_wakes_completion()
String::new(),
"turn-1".into(),
codex_app_server_protocol::ThreadItemPayload::AgentMessage {
moderation: None,
id: "msg-1".into(),
text: "café\nsecond line".into(),
phase: None,
+1
View File
@@ -7537,6 +7537,7 @@ session_picker_view = "dense"
}],
}),
ThreadItem::untimed(ThreadItemPayload::AgentMessage {
moderation: None,
id: String::from("agent-1"),
text: String::from("hello from assistant"),
phase: None,
@@ -40,6 +40,7 @@ fn rollout_user_message(text: &str) -> RolloutItem {
fn rollout_agent_message(text: &str) -> RolloutItem {
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: text.to_string(),
phase: None,
memory_citation: None,
@@ -273,6 +274,7 @@ fn legacy_transcript_preview_falls_back_when_scan_budget_is_exhausted() {
#[test]
fn transcript_preview_reverse_scan_stops_before_older_items() {
let newest = ThreadItemPayload::AgentMessage {
moderation: None,
id: String::from("final"),
text: String::from("one\ntwo\nthree\nfour\nfive\nsix"),
phase: None,
@@ -27,6 +27,7 @@ fn agent_message_notification(turn_id: &str, text: &str) -> ServerNotification {
"thread-1".to_string(),
turn_id.to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "message-1".to_string(),
text: text.to_string(),
phase: None,
@@ -164,6 +164,7 @@ fn joins_respect_actual_turns_and_intervening_items() {
expected[0].transcript_lines(/*width*/ 80),
);
turns[0].items[1] = ThreadItem::untimed(ThreadItemPayload::AgentMessage {
moderation: None,
id: "message".to_string(),
text: "Visible boundary".to_string(),
phase: None,
@@ -154,6 +154,13 @@ class AgentMessageInputContent(
root: InputTextAgentMessageInputContent | EncryptedContentAgentMessageInputContent
class AgentMessageModeration(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
moderated: bool
class AgentPath(RootModel[str]):
root: str
@@ -10715,6 +10722,7 @@ class AgentMessageThreadItem(BaseModel):
delivery: AgentMessageDelivery | None = None
id: str
memory_citation: Annotated[MemoryCitation | None, Field(alias="memoryCitation")] = None
moderation: AgentMessageModeration | None = None
phase: MessagePhase | None = None
questions: list[AsyncUserInputQuestion] | None = None
started_at_ms: Annotated[