[codex] Add optional IDs to response items (#28812)

## Why

`ResponseItem` variants do not have a consistent internal ID shape: some
variants carry required IDs, some carry optional IDs, and some cannot
represent an ID at all. The existing fields also use inconsistent serde,
TypeScript, and JSON-schema annotations. A single enum-level access path
is needed before history recording can assign and retain IDs.

This PR establishes that internal model only. It intentionally does not
generate or serialize IDs; allocation and wire persistence are isolated
in the stacked follow-up.

## What changed

- Give every concrete `ResponseItem` variant an `Option<String>` ID
field.
- Apply the same internal-only annotations to every ID field:
`#[serde(default, skip_serializing)]`, `#[ts(skip)]`, and
`#[schemars(skip)]`.
- Add `ResponseItem::id()` and `ResponseItem::set_id()` as the shared
accessors.
- Preserve IDs when history items are rewritten for truncation.
- Adapt consumers that previously assumed reasoning and image-generation
IDs were required.
- Regenerate app-server schemas so the hidden fields are represented
consistently.

The serde catch-all `ResponseItem::Other` remains ID-less because it
must remain a unit variant.

## Test plan

- `cargo check --tests -p codex-core -p codex-api -p codex-rollout-trace
-p codex-image-generation-extension`
- `just test -p codex-protocol`
- `just test -p codex-app-server-protocol`
- `just test -p codex-api -p codex-rollout-trace -p
codex-image-generation-extension`
- `just test -p codex-core event_mapping`
This commit is contained in:
pakrym-oai
2026-06-17 18:27:43 -07:00
committed by GitHub
Unverified
parent c274a83f8b
commit dbd2857f4b
36 changed files with 278 additions and 258 deletions
+120 -9
View File
@@ -920,6 +920,7 @@ pub enum ResponseItem {
Message {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
role: String,
content: Vec<ContentItem>,
@@ -934,6 +935,10 @@ pub enum ResponseItem {
metadata: Option<ResponseItemMetadata>,
},
AgentMessage {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
author: String,
recipient: String,
content: Vec<AgentMessageInputContent>,
@@ -945,7 +950,7 @@ pub enum ResponseItem {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: String,
id: Option<String>,
summary: Vec<ReasoningItemReasoningSummary>,
#[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
#[ts(optional)]
@@ -959,6 +964,7 @@ pub enum ResponseItem {
/// Legacy id field retained for compatibility with older payloads.
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
/// Set when using the Responses API.
call_id: Option<String>,
@@ -971,6 +977,7 @@ pub enum ResponseItem {
FunctionCall {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -988,6 +995,7 @@ pub enum ResponseItem {
ToolSearchCall {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1006,6 +1014,10 @@ pub enum ResponseItem {
// - an array of structured content items (`content_items`)
// We keep this behavior centralized in `FunctionCallOutputPayload`.
FunctionCallOutput {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
call_id: String,
#[ts(as = "FunctionCallOutputBody")]
#[schemars(with = "FunctionCallOutputBody")]
@@ -1017,6 +1029,7 @@ pub enum ResponseItem {
CustomToolCall {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -1033,6 +1046,10 @@ pub enum ResponseItem {
// `function_call_output.output` so freeform tools can return either plain
// text or structured content items.
CustomToolCallOutput {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -1045,6 +1062,10 @@ pub enum ResponseItem {
metadata: Option<ResponseItemMetadata>,
},
ToolSearchOutput {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
call_id: Option<String>,
status: String,
execution: String,
@@ -1065,6 +1086,7 @@ pub enum ResponseItem {
WebSearchCall {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -1086,7 +1108,10 @@ pub enum ResponseItem {
// "result":"..."
// }
ImageGenerationCall {
id: String,
/// Existing provider ID retained on serialized history for compatibility.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -1098,17 +1123,29 @@ pub enum ResponseItem {
},
#[serde(alias = "compaction_summary")]
Compaction {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
encrypted_content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
metadata: Option<ResponseItemMetadata>,
},
CompactionTrigger {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
metadata: Option<ResponseItemMetadata>,
},
ContextCompaction {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
encrypted_content: Option<String>,
@@ -1126,6 +1163,50 @@ impl ResponseItem {
matches!(self, Self::Message { role, .. } if role == "user")
}
/// Returns the non-empty Responses API item ID, if present.
pub fn id(&self) -> Option<&str> {
match self {
Self::Message { id, .. }
| Self::AgentMessage { id, .. }
| Self::LocalShellCall { id, .. }
| Self::FunctionCall { id, .. }
| Self::ToolSearchCall { id, .. }
| Self::FunctionCallOutput { id, .. }
| Self::CustomToolCall { id, .. }
| Self::CustomToolCallOutput { id, .. }
| Self::ToolSearchOutput { id, .. }
| Self::WebSearchCall { id, .. }
| Self::Reasoning { id, .. }
| Self::ImageGenerationCall { id, .. }
| Self::Compaction { id, .. }
| Self::CompactionTrigger { id, .. }
| Self::ContextCompaction { id, .. } => id.as_deref().filter(|id| !id.is_empty()),
Self::Other => None,
}
}
/// Sets the Responses API item ID for variants that carry one.
pub fn set_id(&mut self, new_id: String) {
match self {
Self::Message { id, .. }
| Self::AgentMessage { id, .. }
| Self::LocalShellCall { id, .. }
| Self::FunctionCall { id, .. }
| Self::ToolSearchCall { id, .. }
| Self::FunctionCallOutput { id, .. }
| Self::CustomToolCall { id, .. }
| Self::CustomToolCallOutput { id, .. }
| Self::ToolSearchOutput { id, .. }
| Self::WebSearchCall { id, .. }
| Self::Reasoning { id, .. }
| Self::ImageGenerationCall { id, .. }
| Self::Compaction { id, .. }
| Self::CompactionTrigger { id, .. }
| Self::ContextCompaction { id, .. } => *id = Some(new_id),
Self::Other => {}
}
}
/// Returns the non-empty turn ID stamped onto this item, if present.
pub fn turn_id(&self) -> Option<&str> {
self.metadata()
@@ -1168,7 +1249,7 @@ impl ResponseItem {
| Self::WebSearchCall { metadata, .. }
| Self::ImageGenerationCall { metadata, .. }
| Self::Compaction { metadata, .. }
| Self::CompactionTrigger { metadata }
| Self::CompactionTrigger { metadata, .. }
| Self::ContextCompaction { metadata, .. } => metadata.as_ref(),
Self::Other => None,
}
@@ -1189,7 +1270,7 @@ impl ResponseItem {
| Self::WebSearchCall { metadata, .. }
| Self::ImageGenerationCall { metadata, .. }
| Self::Compaction { metadata, .. }
| Self::CompactionTrigger { metadata }
| Self::CompactionTrigger { metadata, .. }
| Self::ContextCompaction { metadata, .. } => Some(metadata),
Self::Other => None,
}
@@ -1435,6 +1516,7 @@ impl From<ResponseInputItem> for ResponseItem {
metadata: None,
},
ResponseInputItem::FunctionCallOutput { call_id, output } => Self::FunctionCallOutput {
id: None,
call_id,
output,
metadata: None,
@@ -1442,6 +1524,7 @@ impl From<ResponseInputItem> for ResponseItem {
ResponseInputItem::McpToolCallOutput { call_id, output } => {
let output = output.into_function_call_output_payload();
Self::FunctionCallOutput {
id: None,
call_id,
output,
metadata: None,
@@ -1452,6 +1535,7 @@ impl From<ResponseInputItem> for ResponseItem {
name,
output,
} => Self::CustomToolCallOutput {
id: None,
call_id,
name,
output,
@@ -1467,6 +1551,7 @@ impl From<ResponseInputItem> for ResponseItem {
status,
execution,
tools,
id: None,
metadata: None,
},
}
@@ -2060,6 +2145,16 @@ mod tests {
Ok(())
}
#[test]
fn response_item_id_getter_and_setter() {
let mut item = response_item_with_metadata(/*metadata*/ None);
assert_eq!(item.id(), None);
item.set_id("msg_test".to_string());
assert_eq!(item.id(), Some("msg_test"));
}
fn response_item_with_metadata(metadata: Option<ResponseItemMetadata>) -> ResponseItem {
ResponseItem::Message {
id: None,
@@ -2176,7 +2271,7 @@ mod tests {
assert_eq!(
item,
ResponseItem::ImageGenerationCall {
id: "ig_123".to_string(),
id: Some("ig_123".to_string()),
status: "completed".to_string(),
revised_prompt: Some("A small blue square".to_string()),
result: "Zm9v".to_string(),
@@ -2198,7 +2293,7 @@ mod tests {
assert_eq!(
item,
ResponseItem::ImageGenerationCall {
id: "ig_123".to_string(),
id: Some("ig_123".to_string()),
status: "completed".to_string(),
revised_prompt: None,
result: "Zm9v".to_string(),
@@ -2900,6 +2995,7 @@ mod tests {
assert_eq!(
item,
ResponseItem::Compaction {
id: None,
encrypted_content: "abc".into(),
metadata: None,
}
@@ -2916,6 +3012,7 @@ mod tests {
assert_eq!(
item,
ResponseItem::ContextCompaction {
id: None,
encrypted_content: Some("abc".into()),
metadata: None,
}
@@ -2925,7 +3022,10 @@ mod tests {
#[test]
fn serializes_compaction_trigger_without_payload() -> Result<()> {
let item = ResponseItem::CompactionTrigger { metadata: None };
let item = ResponseItem::CompactionTrigger {
id: None,
metadata: None,
};
assert_eq!(
serde_json::to_value(item)?,
@@ -2938,7 +3038,10 @@ mod tests {
#[test]
fn serializes_stamped_compaction_trigger_metadata() -> Result<()> {
let mut item = ResponseItem::CompactionTrigger { metadata: None };
let mut item = ResponseItem::CompactionTrigger {
id: None,
metadata: None,
};
item.stamp_turn_id_if_missing("turn-1");
assert_eq!(
@@ -2959,7 +3062,13 @@ mod tests {
let item: ResponseItem = serde_json::from_str(json)?;
assert_eq!(item, ResponseItem::CompactionTrigger { metadata: None });
assert_eq!(
item,
ResponseItem::CompactionTrigger {
id: None,
metadata: None,
}
);
Ok(())
}
@@ -3188,6 +3297,7 @@ mod tests {
assert_eq!(
ResponseItem::from(input.clone()),
ResponseItem::ToolSearchOutput {
id: None,
call_id: Some("search-1".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
@@ -3275,6 +3385,7 @@ mod tests {
assert_eq!(
parsed_output,
ResponseItem::ToolSearchOutput {
id: None,
call_id: None,
status: "completed".to_string(),
execution: "server".to_string(),
+2
View File
@@ -762,6 +762,7 @@ impl InterAgentCommunication {
}],
};
ResponseItem::AgentMessage {
id: None,
author: self.author.to_string(),
recipient: self.recipient.to_string(),
content,
@@ -4284,6 +4285,7 @@ mod tests {
assert_eq!(
communication.to_model_input_item(),
ResponseItem::AgentMessage {
id: None,
author: "/root/worker".to_string(),
recipient: "/root".to_string(),
content: vec![