[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
+79 -1
View File
@@ -2287,6 +2287,12 @@
},
"type": "array"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -2337,6 +2343,12 @@
},
"type": "array"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -2385,6 +2397,12 @@
"null"
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -2428,6 +2446,13 @@
"null"
]
},
"id": {
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -2465,6 +2490,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -2513,6 +2544,12 @@
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -2550,6 +2587,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -2584,6 +2627,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"input": {
"type": "string"
},
@@ -2628,6 +2677,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -2674,6 +2729,12 @@
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -2720,6 +2781,12 @@
}
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -2753,7 +2820,6 @@
{
"properties": {
"id": {
"description": "Existing provider ID retained on serialized history for compatibility.",
"type": [
"string",
"null"
@@ -2802,6 +2868,12 @@
"encrypted_content": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -2861,6 +2933,12 @@
"null"
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -14833,6 +14833,12 @@
},
"type": "array"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -14883,6 +14889,12 @@
},
"type": "array"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -14931,6 +14943,12 @@
"null"
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -14974,6 +14992,13 @@
"null"
]
},
"id": {
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -15011,6 +15036,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -15059,6 +15090,12 @@
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -15096,6 +15133,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -15130,6 +15173,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"input": {
"type": "string"
},
@@ -15174,6 +15223,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -15220,6 +15275,12 @@
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -15266,6 +15327,12 @@
}
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -15299,7 +15366,6 @@
{
"properties": {
"id": {
"description": "Existing provider ID retained on serialized history for compatibility.",
"type": [
"string",
"null"
@@ -15348,6 +15414,12 @@
"encrypted_content": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -15407,6 +15479,12 @@
"null"
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -11257,6 +11257,12 @@
},
"type": "array"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -11307,6 +11313,12 @@
},
"type": "array"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -11355,6 +11367,12 @@
"null"
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -11398,6 +11416,13 @@
"null"
]
},
"id": {
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -11435,6 +11460,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -11483,6 +11514,12 @@
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -11520,6 +11557,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -11554,6 +11597,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"input": {
"type": "string"
},
@@ -11598,6 +11647,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -11644,6 +11699,12 @@
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -11690,6 +11751,12 @@
}
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -11723,7 +11790,6 @@
{
"properties": {
"id": {
"description": "Existing provider ID retained on serialized history for compatibility.",
"type": [
"string",
"null"
@@ -11772,6 +11838,12 @@
"encrypted_content": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -11831,6 +11903,12 @@
"null"
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -377,6 +377,12 @@
},
"type": "array"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -427,6 +433,12 @@
},
"type": "array"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -475,6 +487,12 @@
"null"
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -518,6 +536,13 @@
"null"
]
},
"id": {
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -555,6 +580,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -603,6 +634,12 @@
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -640,6 +677,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -674,6 +717,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"input": {
"type": "string"
},
@@ -718,6 +767,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -764,6 +819,12 @@
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -810,6 +871,12 @@
}
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -843,7 +910,6 @@
{
"properties": {
"id": {
"description": "Existing provider ID retained on serialized history for compatibility.",
"type": [
"string",
"null"
@@ -892,6 +958,12 @@
"encrypted_content": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -951,6 +1023,12 @@
"null"
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -448,6 +448,12 @@
},
"type": "array"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -498,6 +504,12 @@
},
"type": "array"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -546,6 +558,12 @@
"null"
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -589,6 +607,13 @@
"null"
]
},
"id": {
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -626,6 +651,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -674,6 +705,12 @@
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -711,6 +748,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -745,6 +788,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"input": {
"type": "string"
},
@@ -789,6 +838,12 @@
"call_id": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -835,6 +890,12 @@
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -881,6 +942,12 @@
}
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -914,7 +981,6 @@
{
"properties": {
"id": {
"description": "Existing provider ID retained on serialized history for compatibility.",
"type": [
"string",
"null"
@@ -963,6 +1029,12 @@
"encrypted_content": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -1022,6 +1094,12 @@
"null"
]
},
"id": {
"type": [
"string",
"null"
]
},
"metadata": {
"anyOf": [
{
@@ -12,12 +12,12 @@ import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSumm
import type { ResponseItemMetadata } from "./ResponseItemMetadata";
import type { WebSearchAction } from "./WebSearchAction";
export type ResponseItem = { "type": "message", role: string, content: Array<ContentItem>, phase?: MessagePhase, metadata?: ResponseItemMetadata, } | { "type": "agent_message", author: string, recipient: string, content: Array<AgentMessageInputContent>, metadata?: ResponseItemMetadata, } | { "type": "reasoning", summary: Array<ReasoningItemReasoningSummary>, content?: Array<ReasoningItemContent>, encrypted_content: string | null, metadata?: ResponseItemMetadata, } | { "type": "local_shell_call",
export type ResponseItem = { "type": "message", id?: string, role: string, content: Array<ContentItem>, phase?: MessagePhase, metadata?: ResponseItemMetadata, } | { "type": "agent_message", id?: string, author: string, recipient: string, content: Array<AgentMessageInputContent>, metadata?: ResponseItemMetadata, } | { "type": "reasoning", id?: string, summary: Array<ReasoningItemReasoningSummary>, content?: Array<ReasoningItemContent>, encrypted_content: string | null, metadata?: ResponseItemMetadata, } | { "type": "local_shell_call",
/**
* Legacy id field retained for compatibility with older payloads.
*/
id?: string,
/**
* Set when using the Responses API.
*/
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, metadata?: ResponseItemMetadata, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, metadata?: ResponseItemMetadata, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, metadata?: ResponseItemMetadata, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], metadata?: ResponseItemMetadata, } | { "type": "web_search_call", status?: string, action?: WebSearchAction, metadata?: ResponseItemMetadata, } | { "type": "image_generation_call",
/**
* Existing provider ID retained on serialized history for compatibility.
*/
id?: string, status: string, revised_prompt?: string, result: string, metadata?: ResponseItemMetadata, } | { "type": "compaction", encrypted_content: string, metadata?: ResponseItemMetadata, } | { "type": "compaction_trigger", metadata?: ResponseItemMetadata, } | { "type": "context_compaction", encrypted_content?: string, metadata?: ResponseItemMetadata, } | { "type": "other" };
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, metadata?: ResponseItemMetadata, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, metadata?: ResponseItemMetadata, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, metadata?: ResponseItemMetadata, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, input: string, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], metadata?: ResponseItemMetadata, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, metadata?: ResponseItemMetadata, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, metadata?: ResponseItemMetadata, } | { "type": "compaction", id?: string, encrypted_content: string, metadata?: ResponseItemMetadata, } | { "type": "compaction_trigger", metadata?: ResponseItemMetadata, } | { "type": "context_compaction", id?: string, encrypted_content?: string, metadata?: ResponseItemMetadata, } | { "type": "other" };
+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()));
}
}
}
}
+7 -4
View File
@@ -301,7 +301,7 @@ async fn responses_client_uses_responses_path() -> Result<()> {
}
#[tokio::test]
async fn responses_client_stream_request_preserves_exact_json_body() -> Result<()> {
async fn responses_client_stream_request_preserves_item_ids() -> Result<()> {
let state = RecordingState::default();
let transport = RecordingTransport::new(state.clone());
let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth));
@@ -327,7 +327,7 @@ async fn responses_client_stream_request_preserves_exact_json_body() -> Result<(
text: None,
client_metadata: None,
};
let expected = serde_json::to_vec(&request)?;
let expected = serde_json::to_value(&request)?;
let _stream = client
.stream_request(request, ResponsesOptions::default())
@@ -338,7 +338,10 @@ async fn responses_client_stream_request_preserves_exact_json_body() -> Result<(
let prepared = requests[0]
.prepare_body_for_send()
.expect("body should prepare");
assert_eq!(prepared.body.as_deref(), Some(expected.as_slice()));
let body: serde_json::Value =
serde_json::from_slice(prepared.body.as_deref().expect("body should be JSON"))?;
assert_eq!(body, expected);
assert_eq!(body["input"][0]["id"], "msg_1");
assert_eq!(
prepared.headers.get(http::header::CONTENT_TYPE),
Some(&HeaderValue::from_static("application/json"))
@@ -502,7 +505,7 @@ async fn streaming_client_does_not_retry_auth_build_error() -> Result<()> {
}
#[tokio::test]
async fn azure_default_store_attaches_ids_and_headers() -> Result<()> {
async fn azure_store_sends_ids_and_headers() -> Result<()> {
let state = RecordingState::default();
let transport = RecordingTransport::new(state.clone());
let client = ResponsesClient::new(transport, provider("azure"), Arc::new(NoAuth));
+6
View File
@@ -530,6 +530,9 @@
"in_app_browser": {
"type": "boolean"
},
"item_ids": {
"type": "boolean"
},
"js_repl": {
"type": "boolean"
},
@@ -4760,6 +4763,9 @@
"in_app_browser": {
"type": "boolean"
},
"item_ids": {
"type": "boolean"
},
"js_repl": {
"type": "boolean"
},
+23 -2
View File
@@ -177,6 +177,7 @@ struct ModelClientState {
enable_request_compression: bool,
include_timing_metrics: bool,
beta_features_header: Option<String>,
item_ids_enabled: bool,
include_attestation: bool,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
disable_websockets: AtomicBool,
@@ -377,6 +378,7 @@ impl ModelClient {
enable_request_compression: bool,
include_timing_metrics: bool,
beta_features_header: Option<String>,
item_ids_enabled: bool,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
) -> Self {
let model_provider = create_model_provider(provider_info, auth_manager);
@@ -397,6 +399,7 @@ impl ModelClient {
enable_request_compression,
include_timing_metrics,
beta_features_header,
item_ids_enabled,
include_attestation,
attestation_provider,
disable_websockets: AtomicBool::new(false),
@@ -519,7 +522,7 @@ impl ModelClient {
let ResponsesApiRequest {
model,
instructions,
input,
mut input,
tools,
parallel_tool_calls,
reasoning,
@@ -528,6 +531,7 @@ impl ModelClient {
text,
..
} = request;
self.prepare_response_items_for_request(&mut input, /*store*/ false);
let payload = ApiCompactionInput {
model: &model,
input: &input,
@@ -823,6 +827,16 @@ impl ModelClient {
Ok(request)
}
fn prepare_response_items_for_request(&self, input: &mut [ResponseItem], store: bool) {
if self.state.item_ids_enabled || store {
return;
}
for item in input {
item.set_id(/*new_id*/ None);
}
}
/// Returns whether the Responses-over-WebSocket transport is active for this session.
///
/// WebSocket use is controlled by provider capability and session-scoped fallback state.
@@ -1292,7 +1306,7 @@ impl ModelClientSession {
)
.await;
let request = self.client.build_responses_request(
let mut request = self.client.build_responses_request(
&client_setup.api_provider,
prompt,
model_info,
@@ -1301,6 +1315,9 @@ impl ModelClientSession {
service_tier.clone(),
responses_metadata,
)?;
let store = request.store;
self.client
.prepare_response_items_for_request(&mut request.input, store);
let inference_trace_attempt = inference_trace.start_attempt();
inference_trace_attempt.add_request_headers(&mut options.extra_headers);
inference_trace_attempt.record_started(&request);
@@ -1468,6 +1485,10 @@ impl ModelClientSession {
inference_trace.start_attempt()
};
stamp_ws_stream_request_start_ms(&mut ws_request);
let ResponsesWsRequest::ResponseCreate(ws_payload) = &mut ws_request;
let store = ws_payload.store;
self.client
.prepare_response_items_for_request(&mut ws_payload.input, store);
if previous_response_id_from_untraced_warmup {
// The transport can reuse an untraced warmup response id and omit the
// already-sent input, but rollout replay needs the logical model-visible
+2
View File
@@ -76,6 +76,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient {
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
/*attestation_provider*/ None,
)
}
@@ -566,6 +567,7 @@ fn model_client_with_counting_attestation(
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
Some(Arc::new(CountingAttestationProvider {
calls: attestation_calls.clone(),
})),
+1 -4
View File
@@ -231,10 +231,7 @@ async fn run_remote_compact_task_inner_impl(
)
.await?;
let mut input = prompt_input.clone();
input.push(ResponseItem::CompactionTrigger {
id: None,
metadata: None,
});
input.push(ResponseItem::CompactionTrigger { metadata: None });
let prompt = Prompt {
input,
tools: tool_router.model_visible_specs(),
+35 -5
View File
@@ -2664,17 +2664,47 @@ impl Session {
turn_context: &TurnContext,
items: &'a [ResponseItem],
) -> Cow<'a, [ResponseItem]> {
if !turn_context
let mut items = Cow::Borrowed(items);
if turn_context
.config
.features
.enabled(Feature::ResizeAllImages)
{
return Cow::Borrowed(items);
prepare_response_items(items.to_mut());
}
if turn_context.config.features.enabled(Feature::ItemIds) {
Self::assign_missing_response_item_ids(&mut items);
}
items
}
let mut prepared_items = items.to_vec();
prepare_response_items(&mut prepared_items);
Cow::Owned(prepared_items)
fn assign_missing_response_item_ids(items: &mut Cow<'_, [ResponseItem]>) {
if items.iter().all(|item| item.id().is_some()) {
return;
}
for item in items.to_mut() {
if item.id().is_some() {
continue;
}
let prefix = match item {
ResponseItem::Message { .. } => "msg",
ResponseItem::Reasoning { .. } => "rs",
ResponseItem::LocalShellCall { .. } => "lsh",
ResponseItem::FunctionCall { .. } => "fc",
ResponseItem::ToolSearchCall { .. } => "tsc",
ResponseItem::FunctionCallOutput { .. } => "fco",
ResponseItem::CustomToolCall { .. } => "ctc",
ResponseItem::CustomToolCallOutput { .. } => "ctco",
ResponseItem::ToolSearchOutput { .. } => "tso",
ResponseItem::WebSearchCall { .. } => "ws",
ResponseItem::ImageGenerationCall { .. } => "ig",
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => "cmp",
ResponseItem::AgentMessage { .. }
| ResponseItem::CompactionTrigger { .. }
| ResponseItem::Other => continue,
};
item.set_id(Some(format!("{prefix}_{}", Uuid::now_v7())));
}
}
pub(crate) fn response_item_from_user_input(
+1
View File
@@ -1039,6 +1039,7 @@ impl Session {
config.features.enabled(Feature::EnableRequestCompression),
config.features.enabled(Feature::RuntimeMetrics),
Self::build_model_client_beta_features_header(config.as_ref()),
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
attestation_provider,
)
.with_prompt_cache_key_override(
+38 -5
View File
@@ -42,6 +42,7 @@ use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::AgentMessageInputContent;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::FunctionCallOutputBody;
@@ -198,6 +199,27 @@ fn user_message(text: &str) -> ResponseItem {
}
}
#[test]
fn assign_missing_response_item_ids_skips_agent_messages() {
let mut items = Cow::Owned(vec![
ResponseItem::AgentMessage {
id: None,
author: "worker".to_string(),
recipient: "root".to_string(),
content: vec![AgentMessageInputContent::InputText {
text: "done".to_string(),
}],
metadata: None,
},
user_message("hello"),
]);
Session::assign_missing_response_item_ids(&mut items);
assert_eq!(items[0].id(), None);
assert!(items[1].id().is_some_and(|id| id.starts_with("msg_")));
}
fn assistant_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
@@ -438,6 +460,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession {
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
/*attestation_provider*/ None,
)
.new_session()
@@ -1670,6 +1693,7 @@ async fn resize_all_images_prepares_failures_before_history_insertion() {
Vec::new(),
|config| {
let _ = config.features.enable(Feature::ResizeAllImages);
let _ = config.features.enable(Feature::ItemIds);
},
)
.await;
@@ -1699,8 +1723,18 @@ async fn resize_all_images_prepares_failures_before_history_insertion() {
.record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&item))
.await;
let history = session.state.lock().await.clone_history();
let id = history.raw_items()[0]
.id()
.expect("history item should have an ID")
.to_string();
let uuid = id
.strip_prefix("fco_")
.expect("function call output ID should have the Responses API prefix");
let parsed_id = Uuid::parse_str(uuid).expect("history item should have a UUID ID");
assert_eq!(parsed_id.get_version(), Some(uuid::Version::SortRand));
let expected = vec![ResponseItem::FunctionCallOutput {
id: None,
id: Some(id),
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
@@ -1719,10 +1753,7 @@ async fn resize_all_images_prepares_failures_before_history_insertion() {
},
metadata: None,
}];
assert_eq!(
session.state.lock().await.clone_history().raw_items(),
expected.as_slice()
);
assert_eq!(history.raw_items(), expected.as_slice());
}
#[tokio::test]
@@ -5059,6 +5090,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
config.features.enabled(Feature::EnableRequestCompression),
config.features.enabled(Feature::RuntimeMetrics),
Session::build_model_client_beta_features_header(config.as_ref()),
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
/*attestation_provider*/ None,
),
code_mode_service: crate::tools::code_mode::CodeModeService::new(),
@@ -7113,6 +7145,7 @@ where
config.features.enabled(Feature::EnableRequestCompression),
config.features.enabled(Feature::RuntimeMetrics),
Session::build_model_client_beta_features_header(config.as_ref()),
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
/*attestation_provider*/ None,
),
code_mode_service: crate::tools::code_mode::CodeModeService::new(),
+3
View File
@@ -127,6 +127,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
/*attestation_provider*/ None,
);
let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source);
@@ -258,6 +259,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
/*attestation_provider*/ None,
);
let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source);
@@ -375,6 +377,7 @@ async fn responses_respects_model_info_overrides_from_config() {
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
/*attestation_provider*/ None,
);
let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source);
+83
View File
@@ -134,6 +134,22 @@ fn message_input_text_contains(request: &ResponsesRequest, role: &str, needle: &
.any(|text| text.contains(needle))
}
fn response_message_item_id(request: &ResponsesRequest, role: &str, text: &str) -> String {
request
.inputs_of_type("message")
.into_iter()
.find(|item| {
item.get("role").and_then(serde_json::Value::as_str) == Some(role)
&& message_input_texts(item).contains(&text)
})
.and_then(|item| {
item.get("id")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| panic!("missing item ID for {role} message {text:?}"))
}
fn assert_codex_client_metadata(
request_body: &serde_json::Value,
installation_id: &str,
@@ -216,9 +232,74 @@ async fn non_openai_responses_requests_omit_item_turn_metadata() {
item.get("metadata").is_none(),
"input item should omit metadata: {item}"
);
assert!(
item.get("id").is_none(),
"input item should omit generated IDs: {item}"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn response_item_ids_persist_across_resume_and_preserve_server_ids() -> anyhow::Result<()> {
let server = MockServer::start().await;
let response_mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg_server", "first reply"),
ev_completed("resp-1"),
]),
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
],
)
.await;
let mut builder = test_codex().with_config(|config| {
let _ = config.features.enable(Feature::ItemIds);
});
let initial = builder.build(&server).await?;
let home = Arc::clone(&initial.home);
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
initial.submit_turn("before resume").await?;
initial.codex.submit(Op::Shutdown).await?;
wait_for_event(&initial.codex, |event| {
matches!(event, EventMsg::ShutdownComplete)
})
.await;
builder = builder.with_config(|config| {
let _ = config.features.enable(Feature::ItemIds);
});
let resumed = builder.resume(&server, home, rollout_path).await?;
resumed.submit_turn("after resume").await?;
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
let user_id = response_message_item_id(&requests[0], "user", "before resume");
let user_uuid = user_id
.strip_prefix("msg_")
.expect("message ID should have the Responses API prefix");
assert_eq!(
Uuid::parse_str(user_uuid)?.get_version(),
Some(uuid::Version::SortRand)
);
assert_eq!(
response_message_item_id(&requests[1], "user", "before resume"),
user_id
);
assert_eq!(
response_message_item_id(&requests[1], "assistant", "first reply"),
"msg_server"
);
Ok(())
}
/// Writes an `auth.json` into the provided `codex_home` with the specified parameters.
/// Returns the fake JWT string written to `tokens.id_token`.
#[expect(clippy::unwrap_used)]
@@ -1022,6 +1103,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
/*attestation_provider*/ None,
);
let responses_metadata = test_turn_responses_metadata(&client, thread_id);
@@ -2515,6 +2597,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
/*attestation_provider*/ None,
);
let responses_metadata = test_turn_responses_metadata(&client, thread_id);
@@ -157,7 +157,8 @@ async fn responses_websocket_streams_request() {
let harness = websocket_harness(&server).await;
let mut client_session = harness.client.new_session();
let prompt = prompt_with_input(vec![message_item("hello")]);
let mut prompt = prompt_with_input(vec![message_item("hello")]);
prompt.input[0].set_id(Some("msg_existing".to_string()));
stream_until_complete(&mut client_session, &harness, &prompt).await;
@@ -169,6 +170,7 @@ async fn responses_websocket_streams_request() {
assert_eq!(body["model"].as_str(), Some(MODEL));
assert_eq!(body["stream"], serde_json::Value::Bool(true));
assert_eq!(body["input"].as_array().map(Vec::len), Some(1));
assert_eq!(body["input"][0].get("id"), None);
let handshake = server.single_handshake();
assert_eq!(
handshake.header(OPENAI_BETA_HEADER),
@@ -2189,6 +2191,7 @@ async fn websocket_harness_with_provider_options(
/*enable_request_compression*/ false,
runtime_metrics_enabled,
/*beta_features_header*/ None,
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
/*attestation_provider*/ None,
);
+4 -4
View File
@@ -649,8 +649,8 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> {
);
assert_eq!(
image_generation_calls[0]["id"].as_str(),
Some("ig_123"),
"expected the original image generation call id to be preserved"
None,
"expected the image generation call id to be omitted"
);
assert_eq!(
image_generation_calls[0]["result"].as_str(),
@@ -766,8 +766,8 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima
);
assert_eq!(
image_generation_calls[0]["id"].as_str(),
Some("ig_123"),
"second request should preserve the original generated image call id"
None,
"second request should omit the generated image call id"
);
assert_eq!(
image_generation_calls[0]["result"].as_str(),
+11 -5
View File
@@ -29,7 +29,9 @@ pub(crate) fn recent_input(items: &[ResponseItem]) -> Option<SearchInput> {
fn push_visible_message(messages: &mut Vec<ResponseItem>, item: &ResponseItem) {
match item {
ResponseItem::Message { role, .. } if role == ASSISTANT_ROLE => {
messages.push(item.clone());
let mut message = item.clone();
message.set_id(/*new_id*/ None);
messages.push(message);
}
ResponseItem::AgentMessage {
author,
@@ -50,7 +52,7 @@ fn push_visible_message(messages: &mut Vec<ResponseItem>, item: &ResponseItem) {
}
}
ResponseItem::Message {
id,
id: _,
role,
content,
phase,
@@ -65,7 +67,7 @@ fn push_visible_message(messages: &mut Vec<ResponseItem>, item: &ResponseItem) {
.collect::<Vec<_>>();
if !content.is_empty() {
messages.push(ResponseItem::Message {
id: id.clone(),
id: None,
role: role.clone(),
content,
phase: phase.clone(),
@@ -108,11 +110,15 @@ mod tests {
#[test]
fn keeps_current_user_and_previous_visible_turn() {
let mut previous_user = message(USER_ROLE, "previous user");
previous_user.set_id(Some("msg_previous_user".to_string()));
let mut previous_assistant = message(ASSISTANT_ROLE, "previous assistant");
previous_assistant.set_id(Some("msg_previous_assistant".to_string()));
let items = vec![
message("system", "system"),
message(USER_ROLE, "old user"),
message(ASSISTANT_ROLE, "old assistant"),
message(USER_ROLE, "previous user"),
previous_user,
ResponseItem::FunctionCall {
id: None,
name: "tool".to_string(),
@@ -121,7 +127,7 @@ mod tests {
call_id: "call-1".to_string(),
metadata: None,
},
message(ASSISTANT_ROLE, "previous assistant"),
previous_assistant,
message("developer", "developer"),
message(USER_ROLE, "current user"),
message(ASSISTANT_ROLE, "current commentary"),
+8
View File
@@ -190,6 +190,8 @@ pub enum Feature {
ImageGenExt,
/// Resize all inline data-URL images before recording them in history.
ResizeAllImages,
/// Generate Responses API item IDs for client-created history items.
ItemIds,
/// Allow prompting and installing missing MCP dependencies.
SkillMcpDependencyInstall,
/// Removed compatibility flag for deleted skill env var dependency prompting.
@@ -1134,6 +1136,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ItemIds,
key: "item_ids",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::SkillMcpDependencyInstall,
key: "skill_mcp_dependency_install",
+1
View File
@@ -235,6 +235,7 @@ impl MemoryStartupContext {
config.features.enabled(Feature::EnableRequestCompression),
config.features.enabled(Feature::RuntimeMetrics),
/*beta_features_header*/ None,
config.features.enabled(Feature::ItemIds),
/*attestation_provider*/ None,
);
+43 -77
View File
@@ -918,9 +918,8 @@ pub struct ResponseItemMetadata {
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseItem {
Message {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
role: String,
content: Vec<ContentItem>,
@@ -935,9 +934,8 @@ pub enum ResponseItem {
metadata: Option<ResponseItemMetadata>,
},
AgentMessage {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
author: String,
recipient: String,
@@ -947,9 +945,8 @@ pub enum ResponseItem {
metadata: Option<ResponseItemMetadata>,
},
Reasoning {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
summary: Vec<ReasoningItemReasoningSummary>,
#[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
@@ -962,9 +959,8 @@ pub enum ResponseItem {
},
LocalShellCall {
/// Legacy id field retained for compatibility with older payloads.
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
/// Set when using the Responses API.
call_id: Option<String>,
@@ -975,9 +971,8 @@ pub enum ResponseItem {
metadata: Option<ResponseItemMetadata>,
},
FunctionCall {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -993,9 +988,8 @@ pub enum ResponseItem {
metadata: Option<ResponseItemMetadata>,
},
ToolSearchCall {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1014,9 +1008,8 @@ 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)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
call_id: String,
#[ts(as = "FunctionCallOutputBody")]
@@ -1027,9 +1020,8 @@ pub enum ResponseItem {
metadata: Option<ResponseItemMetadata>,
},
CustomToolCall {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -1046,9 +1038,8 @@ 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)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1062,9 +1053,8 @@ pub enum ResponseItem {
metadata: Option<ResponseItemMetadata>,
},
ToolSearchOutput {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
call_id: Option<String>,
status: String,
@@ -1084,9 +1074,8 @@ pub enum ResponseItem {
// "action": {"type":"search","query":"weather: San Francisco, CA"}
// }
WebSearchCall {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -1108,7 +1097,6 @@ pub enum ResponseItem {
// "result":"..."
// }
ImageGenerationCall {
/// Existing provider ID retained on serialized history for compatibility.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
@@ -1123,28 +1111,24 @@ pub enum ResponseItem {
},
#[serde(alias = "compaction_summary")]
Compaction {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
encrypted_content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
metadata: Option<ResponseItemMetadata>,
},
// Compaction triggers are request controls, and the Responses API does not
// accept an `id` field for them.
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)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -1179,14 +1163,13 @@ impl ResponseItem {
| 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,
Self::CompactionTrigger { .. } | Self::Other => None,
}
}
/// Sets the Responses API item ID for variants that carry one.
pub fn set_id(&mut self, new_id: String) {
/// Sets or clears the Responses API item ID for variants that carry one.
pub fn set_id(&mut self, new_id: Option<String>) {
match self {
Self::Message { id, .. }
| Self::AgentMessage { id, .. }
@@ -1201,9 +1184,8 @@ impl ResponseItem {
| Self::Reasoning { id, .. }
| Self::ImageGenerationCall { id, .. }
| Self::Compaction { id, .. }
| Self::CompactionTrigger { id, .. }
| Self::ContextCompaction { id, .. } => *id = Some(new_id),
Self::Other => {}
| Self::ContextCompaction { id, .. } => *id = new_id,
Self::CompactionTrigger { .. } | Self::Other => {}
}
}
@@ -2150,9 +2132,13 @@ mod tests {
let mut item = response_item_with_metadata(/*metadata*/ None);
assert_eq!(item.id(), None);
item.set_id("msg_test".to_string());
item.set_id(Some("msg_test".to_string()));
assert_eq!(item.id(), Some("msg_test"));
item.set_id(/*new_id*/ None);
assert_eq!(item.id(), None);
}
fn response_item_with_metadata(metadata: Option<ResponseItemMetadata>) -> ResponseItem {
@@ -3022,10 +3008,7 @@ mod tests {
#[test]
fn serializes_compaction_trigger_without_payload() -> Result<()> {
let item = ResponseItem::CompactionTrigger {
id: None,
metadata: None,
};
let item = ResponseItem::CompactionTrigger { metadata: None };
assert_eq!(
serde_json::to_value(item)?,
@@ -3038,10 +3021,7 @@ mod tests {
#[test]
fn serializes_stamped_compaction_trigger_metadata() -> Result<()> {
let mut item = ResponseItem::CompactionTrigger {
id: None,
metadata: None,
};
let mut item = ResponseItem::CompactionTrigger { metadata: None };
item.stamp_turn_id_if_missing("turn-1");
assert_eq!(
@@ -3062,13 +3042,7 @@ mod tests {
let item: ResponseItem = serde_json::from_str(json)?;
assert_eq!(
item,
ResponseItem::CompactionTrigger {
id: None,
metadata: None,
}
);
assert_eq!(item, ResponseItem::CompactionTrigger { metadata: None });
Ok(())
}
@@ -3109,7 +3083,6 @@ mod tests {
queries: Some(vec!["weather seattle".into(), "seattle weather now".into()]),
}),
Some("completed".into()),
true,
),
(
r#"{
@@ -3125,7 +3098,6 @@ mod tests {
url: Some("https://example.com".into()),
}),
Some("open".into()),
true,
),
(
r#"{
@@ -3143,7 +3115,6 @@ mod tests {
pattern: Some("installation".into()),
}),
Some("in_progress".into()),
true,
),
(
r#"{
@@ -3154,12 +3125,10 @@ mod tests {
Some("ws_partial".into()),
None,
Some("in_progress".into()),
false,
),
];
for (json_literal, expected_id, expected_action, expected_status, expect_roundtrip) in cases
{
for (json_literal, expected_id, expected_action, expected_status) in cases {
let parsed: ResponseItem = serde_json::from_str(json_literal)?;
let expected = ResponseItem::WebSearchCall {
id: expected_id.clone(),
@@ -3170,10 +3139,7 @@ mod tests {
assert_eq!(parsed, expected);
let serialized = serde_json::to_value(&parsed)?;
let mut expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?;
if !expect_roundtrip && let Some(obj) = expected_serialized.as_object_mut() {
obj.remove("id");
}
let expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?;
assert_eq!(serialized, expected_serialized);
}
+1
View File
@@ -515,6 +515,7 @@ mod tests {
traced,
json!({
"type": "reasoning",
"id": "rs-1",
"summary": [{"type": "summary_text", "text": "summary"}],
"content": [{"type": "text", "text": "raw reasoning"}],
"encrypted_content": "encoded",