[codex] Assign response item IDs when recording history (#28814)

## Why

Client-created response items enter history without IDs, so their
identity is lost across rollout persistence and resume. IDs should be
assigned once at the history-recording boundary, while IDs returned by
the server must remain unchanged.

The Responses API validates item IDs using type-specific prefixes.
Locally generated IDs therefore use the matching prefix plus a
hyphenated UUIDv7, keeping them valid while distinguishable from
server-generated IDs. Because this changes persisted history and
provider request shapes, the behavior is opt-in behind the
under-development `item_ids` feature. Compaction triggers remain request
controls whose API shape does not accept an ID.

## What changed

- Register the disabled-by-default `item_ids` feature and expose it in
`config.schema.json`.
- Make supported optional `ResponseItem` IDs serializable and expose
them in the generated app-server schemas.
- When `item_ids` is enabled, assign an ID during conversation-history
preparation if an item has no ID.
- Generate type-prefixed, hyphenated UUIDv7 IDs using the Responses API
item conventions.
- Preserve existing server IDs without rewriting them.
- Persist assigned IDs in rollouts and include them in subsequent
Responses requests.
- Remove the unsupported ID field from `CompactionTrigger` and document
why it has no ID.
- Add integration coverage for enabled ID persistence, preservation of
server IDs, and omission of generated IDs while the feature is disabled.

`prepare_conversation_items_for_history` is the single response-item ID
allocation boundary.

## Test plan

- `just test -p codex-features`
- `just test -p codex-core
response_item_ids_persist_across_resume_and_preserve_server_ids`
- `just test -p codex-core
non_openai_responses_requests_omit_item_turn_metadata`
- `just test -p codex-core
resize_all_images_prepares_failures_before_history_insertion`
- `just test -p codex-protocol`
- `just test -p codex-app-server-protocol`
- `just test -p codex-api azure_default_store_attaches_ids_and_headers`
This commit is contained in:
pakrym-oai
2026-06-18 17:30:55 -07:00
committed by GitHub
parent 8e7c213f8f
commit f00f93d8c0
28 changed files with 677 additions and 164 deletions
+1 -2
View File
@@ -9,7 +9,6 @@ use codex_protocol::models::ResponseItem;
use http::HeaderMap;
use http::Method;
use serde::Deserialize;
use serde_json::to_value;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
@@ -76,7 +75,7 @@ impl<T: HttpTransport> CompactClient<T> {
request_timeout: Duration,
turn_state: Option<&OnceLock<String>>,
) -> Result<Vec<ResponseItem>, ApiError> {
let body = to_value(input)
let body = serde_json::to_value(input)
.map_err(|e| ApiError::Stream(format!("failed to encode compaction input: {e}")))?;
self.compact(body, extra_headers, request_timeout, turn_state)
.await
+2 -11
View File
@@ -5,7 +5,6 @@ use crate::endpoint::session::EndpointSession;
use crate::error::ApiError;
use crate::provider::Provider;
use crate::requests::Compression;
use crate::requests::attach_item_ids;
use crate::requests::headers::build_session_headers;
use crate::requests::headers::insert_header;
use crate::requests::headers::subagent_header;
@@ -82,16 +81,8 @@ impl<T: HttpTransport> ResponsesClient<T> {
turn_state,
} = options;
let body = if request.store && self.session.provider().is_azure_responses_endpoint() {
let mut body = serde_json::to_value(&request).map_err(|e| {
ApiError::Stream(format!("failed to encode responses request: {e}"))
})?;
attach_item_ids(&mut body, &request.input);
EncodedJsonBody::encode(&body)
} else {
EncodedJsonBody::encode(&request)
}
.map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?;
let body = EncodedJsonBody::encode(&request)
.map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?;
let mut headers = extra_headers;
if let Some(ref thread_id) = thread_id {
+2 -1
View File
@@ -149,7 +149,7 @@ mod tests {
model: "gpt-test".to_string(),
reasoning: None,
input: Some(SearchInput::Items(vec![ResponseItem::Message {
id: None,
id: Some("msg_search".to_string()),
role: "user".to_string(),
content: vec![
ContentItem::InputText {
@@ -228,6 +228,7 @@ mod tests {
"model": "gpt-test",
"input": [{
"type": "message",
"id": "msg_search",
"role": "user",
"content": [
{"type": "input_text", "text": "find this"},
-1
View File
@@ -2,4 +2,3 @@ pub(crate) mod headers;
pub(crate) mod responses;
pub use responses::Compression;
pub(crate) use responses::attach_item_ids;
@@ -1,37 +1,6 @@
use codex_protocol::models::ResponseItem;
use serde_json::Value;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Compression {
#[default]
None,
Zstd,
}
pub(crate) fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) {
let Some(input_value) = payload_json.get_mut("input") else {
return;
};
let Value::Array(items) = input_value else {
return;
};
for (value, item) in items.iter_mut().zip(original_items.iter()) {
if let ResponseItem::Reasoning { id: Some(id), .. }
| ResponseItem::Message { id: Some(id), .. }
| ResponseItem::WebSearchCall { id: Some(id), .. }
| ResponseItem::FunctionCall { id: Some(id), .. }
| ResponseItem::ToolSearchCall { id: Some(id), .. }
| ResponseItem::LocalShellCall { id: Some(id), .. }
| ResponseItem::CustomToolCall { id: Some(id), .. } = item
{
if id.is_empty() {
continue;
}
if let Some(obj) = value.as_object_mut() {
obj.insert("id".to_string(), Value::String(id.clone()));
}
}
}
}