diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 95ced2fb5d..cc73736b27 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -10,6 +10,7 @@ use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use serde::ser::Serializer; +use serde_with::serde_as; use ts_rs::TS; use crate::local_media::audio_mime_for_path; @@ -32,6 +33,7 @@ use crate::mcp::CallToolResult; use codex_utils_path_uri::PathUri; mod executed_tool_calls; +mod item_metadata; pub use crate::local_media::MAX_PROMPT_AUDIO_INPUT_BYTES; pub use crate::local_media::snapshot_local_user_input; @@ -42,6 +44,7 @@ pub use executed_tool_calls::ExecutedToolCallTruncation; pub use executed_tool_calls::bound_executed_tool_calls_for_prompt; pub use executed_tool_calls::bound_executed_tool_calls_for_prompt_prioritizing_recent; pub use executed_tool_calls::executed_tool_call_metadata_bytes; +pub use item_metadata::ContentItemKind; /// Controls the per-command sandbox override requested by a shell-like tool call. #[derive( @@ -904,6 +907,7 @@ pub enum MessagePhase { /// /// Responses API strongly types this payload. Do not modify it without first getting API /// approval and making the corresponding Responses API change. +#[serde_as] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] pub struct InternalChatMessageMetadataPassthrough { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -914,6 +918,12 @@ pub struct InternalChatMessageMetadataPassthrough { #[schemars(skip)] #[ts(skip)] pub create_time: Option, + /// Harness-owned classifications aligned with the item's content entries. + #[serde_as(deserialize_as = "serde_with::DefaultOnError")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(skip)] + #[ts(skip)] + pub content_item_kinds: Option>, /// Warehouse-only Responses metadata, not part of the public app-server protocol. #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")] #[schemars(skip)] diff --git a/codex-rs/protocol/src/models/item_metadata.rs b/codex-rs/protocol/src/models/item_metadata.rs new file mode 100644 index 0000000000..eddd29ee58 --- /dev/null +++ b/codex-rs/protocol/src/models/item_metadata.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; +use serde::Serialize; + +/// Harness-owned classification for one position in an item's content array. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(transparent)] +pub struct ContentItemKind(pub String); + +#[cfg(test)] +#[path = "item_metadata_tests.rs"] +mod tests; diff --git a/codex-rs/protocol/src/models/item_metadata_tests.rs b/codex-rs/protocol/src/models/item_metadata_tests.rs new file mode 100644 index 0000000000..df8cfadfbd --- /dev/null +++ b/codex-rs/protocol/src/models/item_metadata_tests.rs @@ -0,0 +1,79 @@ +use super::ContentItemKind; +use crate::models::ContentItem; +use crate::models::InternalChatMessageMetadataPassthrough; +use crate::models::ResponseItem; +use anyhow::Result; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; + +#[test] +fn content_kinds_round_trip_without_restricting_future_values() -> Result<()> { + let item = response_item(InternalChatMessageMetadataPassthrough { + turn_id: Some("turn-1".to_string()), + content_item_kinds: Some(vec![ContentItemKind("future_content_kind".to_string())]), + ..Default::default() + }); + + let serialized = serde_json::to_value(&item)?; + assert_eq!( + serialized, + json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + "internal_chat_message_metadata_passthrough": { + "turn_id": "turn-1", + "content_item_kinds": ["future_content_kind"], + }, + }) + ); + assert_eq!(serde_json::from_value::(serialized)?, item); + + Ok(()) +} + +#[test] +fn malformed_content_kinds_do_not_prevent_loading_response_item() -> Result<()> { + for malformed_kinds in [ + json!("user_text"), + json!({"kind": "user_text"}), + json!(["user_text", 123]), + ] { + let item = serde_json::from_value::(response_item_json(json!({ + "turn_id": "turn-1", + "content_item_kinds": malformed_kinds, + })))?; + + assert_eq!( + item, + response_item(InternalChatMessageMetadataPassthrough { + turn_id: Some("turn-1".to_string()), + ..Default::default() + }) + ); + } + + Ok(()) +} + +fn response_item(metadata: InternalChatMessageMetadataPassthrough) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "hello".to_string(), + }], + phase: None, + internal_chat_message_metadata_passthrough: Some(metadata), + } +} + +fn response_item_json(metadata: Value) -> Value { + json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + "internal_chat_message_metadata_passthrough": metadata, + }) +}