Preserve model-facing ID allocations in saved history (#60771)

## Why

Restoring model-facing ID sequences requires allocation ordinals alongside the rendered IDs. Responses API payloads still require string IDs.

## What changed

- Add allocation metadata to `ResponseItemId` while keeping equality, hashing, and ordering based on the rendered ID.
- Preserve optional `id_allocation` metadata in rollout items, inter-agent messages, compaction replacement history, and Guardian checkpoints. Continue accepting legacy string IDs.
- Support structured context-window allocations alongside UUIDv7 strings, validate the `window` salt on restoration, and keep request metadata IDs as strings.

## Testing

Add tests for allocation round trips, legacy ID compatibility, string-only raw response item IDs, and context-window allocation validation.

GitOrigin-RevId: ce1fd964bde9a4e7c527432be99aea87ada30169
This commit is contained in:
Ruslan Nigmatullin
2026-11-11 09:58:13 +00:00
committed by copyberry
parent 32234ddac4
commit 340a18ed7c
15 changed files with 450 additions and 85 deletions
+1
View File
@@ -4663,6 +4663,7 @@ dependencies = [
"codex-network-proxy",
"codex-utils-absolute-path",
"codex-utils-image",
"codex-utils-model-facing-id",
"codex-utils-path-uri",
"codex-utils-redacted-string",
"codex-utils-string",
+1
View File
@@ -321,6 +321,7 @@ codex-utils-head-tail-buffer = { path = "utils/head-tail-buffer" }
codex-utils-home-dir = { path = "utils/home-dir" }
codex-utils-image = { path = "utils/image" }
codex-utils-json-to-toml = { path = "utils/json-to-toml" }
codex-utils-model-facing-id = { path = "utils/model-facing-id" }
codex-utils-oss = { path = "utils/oss" }
codex-utils-output-truncation = { path = "utils/output-truncation" }
codex-utils-path = { path = "utils/path-utils" }
+6 -4
View File
@@ -438,9 +438,11 @@ impl CodexResponsesMetadata {
turn_id: self.turn_id.as_deref(),
window_id: has_request_identity.then_some(self.window_id.as_str()),
window_number: has_request_identity.then_some(self.window_number).flatten(),
context_window_id: has_request_identity
.then_some(self.context_window_id.as_ref())
.flatten(),
context_window_id: self
.context_window_id
.as_ref()
.filter(|_| has_request_identity)
.map(ToString::to_string),
request_kind: request_kind_value,
history_initialization: self.history_initialization,
forked_from_thread_id: self.forked_from_thread_id,
@@ -572,7 +574,7 @@ struct CodexTurnMetadataPayload<'a> {
#[serde(default, skip_serializing_if = "Option::is_none")]
window_number: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
context_window_id: Option<&'a ContextWindowId>,
context_window_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
request_kind: Option<&'static str>,
#[serde(default, skip_serializing_if = "Option::is_none")]
+6 -5
View File
@@ -1,6 +1,6 @@
//! Model-invisible Guardian transcript checkpoints and retained-section delivery proof.
//! Entries preserve rollback provenance; their flattened wire shape still reads as
//! ResponseItem on older hosts, and old metadata-free checkpoints remain readable.
//! Entries preserve rollback provenance and allocation ordinals. Readers also accept
//! metadata-free checkpoints and legacy string IDs.
use std::borrow::Cow;
@@ -11,6 +11,7 @@ use serde::Serialize;
use crate::CodexHarnessMetadata;
use crate::ResponseItemEnvelope;
use crate::rollout_payload::ItemPayload;
/// Host omission notices delivered by a complete retained-instructions section.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -27,7 +28,7 @@ pub struct GuardianHistoryCheckpoint(pub Vec<ResponseItemEnvelope>);
#[derive(Serialize, Deserialize, JsonSchema)]
struct Entry<'a> {
#[serde(flatten)]
item: Cow<'a, ResponseItem>,
item: ItemPayload<'a, ResponseItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
guardian_metadata: Option<Cow<'a, CodexHarnessMetadata>>,
}
@@ -35,7 +36,7 @@ struct Entry<'a> {
impl Serialize for GuardianHistoryCheckpoint {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_seq(self.0.iter().map(|entry| Entry {
item: Cow::Borrowed(&entry.item),
item: (&entry.item).into(),
guardian_metadata: entry.metadata.as_ref().map(Cow::Borrowed),
}))
}
@@ -47,7 +48,7 @@ impl<'de> Deserialize<'de> for GuardianHistoryCheckpoint {
Vec::<Entry<'_>>::deserialize(deserializer)?
.into_iter()
.map(|entry| ResponseItemEnvelope {
item: entry.item.into_owned(),
item: entry.item.into(),
metadata: entry.guardian_metadata.map(Cow::into_owned),
})
.collect(),
+78 -32
View File
@@ -18,6 +18,7 @@ use crate::RetainedContextEvent;
use crate::RetainedUserMessage;
use codex_protocol::ContextWindowId;
use codex_protocol::ResponseItemId;
use codex_protocol::ResponseItemIdAllocation;
use codex_protocol::models::ContentItem;
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
use codex_protocol::permissions::FileSystemSandboxPolicy;
@@ -43,12 +44,12 @@ pub(super) enum RolloutItemWire<'a> {
payload: DynamicToolsUpdatedPayload<'a>,
},
ResponseItem {
payload: Cow<'a, ResponseItem>,
payload: ItemPayload<'a, ResponseItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
metadata: Option<Cow<'a, CodexHarnessMetadata>>,
},
InterAgentCommunication {
payload: Cow<'a, InterAgentCommunication>,
payload: ItemPayload<'a, InterAgentCommunication>,
},
InterAgentCommunicationMetadata {
payload: InterAgentCommunicationMetadataPayload,
@@ -140,6 +141,42 @@ pub(super) struct DynamicToolsUpdatedPayload<'a> {
tools: Cow<'a, [codex_protocol::dynamic_tools::DynamicToolSpec]>,
}
/// The protocol item keeps its wire ID. The optional allocation lets replay resume the sequence.
#[derive(Serialize, Deserialize, JsonSchema)]
pub(super) struct ItemPayload<'a, T: Clone> {
#[serde(flatten)]
item: Cow<'a, T>,
#[serde(default, skip_serializing_if = "Option::is_none")]
id_allocation: Option<Cow<'a, ResponseItemIdAllocation>>,
}
impl<'a, T: Clone> ItemPayload<'a, T> {
fn borrowed(item: &'a T, id: Option<&'a ResponseItemId>) -> Self {
Self {
item: Cow::Borrowed(item),
id_allocation: id.and_then(ResponseItemId::allocation).map(Cow::Borrowed),
}
}
}
impl<'a> From<&'a ResponseItem> for ItemPayload<'a, ResponseItem> {
fn from(item: &'a ResponseItem) -> Self {
Self::borrowed(item, item.id())
}
}
impl From<ItemPayload<'_, ResponseItem>> for ResponseItem {
fn from(payload: ItemPayload<'_, ResponseItem>) -> Self {
let mut item = payload.item.into_owned();
if let Some(allocation) = payload.id_allocation
&& let Some(id) = item.id().cloned()
{
item.set_id(Some(id.with_allocation(allocation.into_owned())));
}
item
}
}
impl<'a> From<&'a RolloutItem> for RolloutItemWire<'a> {
fn from(item: &'a RolloutItem) -> Self {
match item {
@@ -153,11 +190,11 @@ impl<'a> From<&'a RolloutItem> for RolloutItemWire<'a> {
},
},
RolloutItem::ResponseItem(envelope) => Self::ResponseItem {
payload: Cow::Borrowed(&envelope.item),
payload: (&envelope.item).into(),
metadata: envelope.metadata.as_ref().map(Cow::Borrowed),
},
RolloutItem::InterAgentCommunication(payload) => Self::InterAgentCommunication {
payload: Cow::Borrowed(payload),
payload: ItemPayload::borrowed(payload, payload.id.as_ref()),
},
RolloutItem::InterAgentCommunicationMetadata { trigger_turn } => {
Self::InterAgentCommunicationMetadata {
@@ -194,27 +231,30 @@ impl<'a> From<&'a RolloutItem> for RolloutItemWire<'a> {
format!("{CODE_MODE_DELIVERY_INCOMPLETE}{}", message.text)
};
Self::ResponseItem {
payload: Cow::Owned(ResponseItem::Message {
status: None,
encrypted_content: None,
id: message.message_id.clone().map(ResponseItemId::from_server),
role: "assistant".to_owned(),
content: vec![ContentItem::OutputText {
annotations: None,
logprobs: None,
text,
}],
// Older Guardian clients exclude commentary from root-to-worker
// review. Keep the phase unspecified so they can review this
// confirmed delivery without claiming it ended the turn.
phase: None,
internal_chat_message_metadata_passthrough: Some(
InternalChatMessageMetadataPassthrough {
turn_id: Some(message.turn_id.clone()),
..Default::default()
},
),
}),
payload: ItemPayload {
item: Cow::Owned(ResponseItem::Message {
status: None,
encrypted_content: None,
id: message.message_id.clone().map(ResponseItemId::from_server),
role: "assistant".to_owned(),
content: vec![ContentItem::OutputText {
annotations: None,
logprobs: None,
text,
}],
// Older Guardian clients exclude commentary from root-to-worker
// review. Keep the phase unspecified so they can review this
// confirmed delivery without claiming it ended the turn.
phase: None,
internal_chat_message_metadata_passthrough: Some(
InternalChatMessageMetadataPassthrough {
turn_id: Some(message.turn_id.clone()),
..Default::default()
},
),
}),
id_allocation: None,
},
metadata: Some(Cow::Owned(CodexHarnessMetadata {
delivered_assistant_message: Some(marker),
user_input_order: Some(*acceptance_order),
@@ -258,7 +298,7 @@ impl From<RolloutItemWire<'_>> for RolloutItem {
..
},
Some(metadata),
) = (payload.as_ref(), metadata.as_deref())
) = (payload.item.as_ref(), metadata.as_deref())
&& role == "assistant"
&& let [ContentItem::OutputText { text, .. }] = content.as_slice()
&& let Some(turn_id) = passthrough.turn_id.as_ref()
@@ -287,12 +327,18 @@ impl From<RolloutItemWire<'_>> for RolloutItem {
);
}
Self::ResponseItem(ResponseItemEnvelope {
item: payload.into_owned(),
item: payload.into(),
metadata: metadata.map(Cow::into_owned),
})
}
RolloutItemWire::InterAgentCommunication { payload } => {
Self::InterAgentCommunication(payload.into_owned())
let mut message = payload.item.into_owned();
if let Some(allocation) = payload.id_allocation
&& let Some(id) = message.id.take()
{
message.id = Some(id.with_allocation(allocation.into_owned()));
}
Self::InterAgentCommunication(message)
}
RolloutItemWire::InterAgentCommunicationMetadata { payload } => {
Self::InterAgentCommunicationMetadata {
@@ -326,7 +372,7 @@ pub(super) struct InterAgentCommunicationMetadataPayload {
pub(super) struct CompactedItemWire<'a> {
message: Cow<'a, str>,
#[serde(default, skip_serializing_if = "Option::is_none")]
replacement_history: Option<Vec<Cow<'a, ResponseItem>>>,
replacement_history: Option<Vec<ItemPayload<'a, ResponseItem>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
replacement_history_metadata: Option<Vec<Cow<'a, CodexHarnessMetadata>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -373,7 +419,7 @@ impl<'a> From<&'a CompactedItem> for CompactedItemWire<'a> {
replacement_history: item.replacement_history.as_ref().map(|items| {
items
.iter()
.map(|envelope| Cow::Borrowed(&envelope.item))
.map(|envelope| (&envelope.item).into())
.collect()
}),
replacement_history_metadata,
@@ -415,7 +461,7 @@ impl TryFrom<CompactedItemWire<'_>> for CompactedItem {
.into_iter()
.zip(metadata)
.map(|(item, metadata)| ResponseItemEnvelope {
item: item.into_owned(),
item: item.into(),
metadata: Some(metadata.into_owned()),
})
.collect(),
@@ -424,7 +470,7 @@ impl TryFrom<CompactedItemWire<'_>> for CompactedItem {
(Some(items), None) => Some(
items
.into_iter()
.map(|item| ResponseItemEnvelope::new(item.into_owned()))
.map(|item| ResponseItemEnvelope::new(item.into()))
.collect(),
),
(None, Some(_)) => {
+128 -5
View File
@@ -274,6 +274,86 @@ fn response_item_rollout_line_preserves_shape() -> Result<()> {
Ok(())
}
#[test]
fn rollout_ids_preserve_allocations_but_responses_ids_are_strings() -> Result<()> {
for (rendered, allocation) in [
("msg_item042", None),
(
"msg_item042",
Some(json!({"prefix":"msg","salt":"item","ordinal":9,"value":42})),
),
(
"item042_2",
Some(json!({"salt":"item","ordinal":17,"value":42,"suffix":"_2"})),
),
(
"provider-output-2",
Some(json!({"salt":"item","ordinal":17,"value":42})),
),
] {
let with_allocation = |mut payload: serde_json::Value| {
if let Some(allocation) = &allocation {
payload["id_allocation"] = allocation.clone();
}
payload
};
let raw = json!({"type":"message","role":"assistant","content":[],"id":rendered});
let payload = with_allocation(raw.clone());
let wire = json!({"type":"response_item","payload":payload});
let record: RolloutItem = serde_json::from_value(wire.clone())?;
assert_eq!(serde_json::to_value(&record)?, wire);
let RolloutItem::ResponseItem(envelope) = record else {
panic!("expected response item")
};
let id = envelope.item.id().expect("item ID");
assert_eq!(id.as_str(), rendered);
assert_eq!(serde_json::to_value(id.allocation())?, json!(allocation));
assert_eq!(serde_json::to_value(&envelope.item)?, raw);
let caller_item: ResponseItem = serde_json::from_value(payload.clone())?;
assert_eq!(caller_item.id().expect("wire ID").allocation(), None);
let raw_message = json!({
"author":"/root","recipient":"/root/child","other_recipients":[],
"content":"hello","trigger_turn":false,"id":rendered
});
let agent_message = json!({"type":"inter_agent_communication","payload":with_allocation(raw_message.clone())});
let record: RolloutItem = serde_json::from_value(agent_message.clone())?;
assert_eq!(serde_json::to_value(&record)?, agent_message);
let RolloutItem::InterAgentCommunication(message) = record else {
unreachable!()
};
assert_eq!(serde_json::to_value(message)?, raw_message);
let stored_id = match &allocation {
Some(allocation) => json!({"id":rendered,"id_allocation":allocation}),
None => json!(rendered),
};
let source = json!({
"id": {"message_id":"message", "turn_id":"turn", "role":"assistant"},
"revision": stored_id,
"complete": true
});
let metadata: CodexHarnessMetadata = serde_json::from_value(json!({
"guardian_review_ids": [stored_id], "retained_source": source,
"guardian_sources": [source]
}))?;
let saved = serde_json::to_value(metadata)?;
assert_eq!(saved["guardian_review_ids"], json!([stored_id]));
assert_eq!(saved["retained_source"], source);
assert_eq!(saved["guardian_sources"], json!([source]));
let compacted: CompactedItem = serde_json::from_value(json!({
"message":"summary",
"replacement_history":[payload],
"guardian_history":[payload],
}))?;
let checkpoint = serde_json::to_value(compacted)?;
assert_eq!(&checkpoint["replacement_history"][0], &payload);
assert_eq!(&checkpoint["guardian_history"][0], &payload);
}
Ok(())
}
#[test]
fn delivered_assistant_rollout_survives_an_earlier_read_and_rewrite() -> Result<()> {
#[derive(serde::Serialize, serde::Deserialize)]
@@ -1004,15 +1084,22 @@ fn rollout_item_schema_matches_tagged_payload_and_sibling_metadata() -> Result<(
.find(|variant| variant["properties"]["type"]["enum"] == json!(["response_item"]))
.expect("response item schema");
assert!(response_item["properties"].get("metadata").is_some());
assert_eq!(
response_item["properties"]["payload"]["$ref"],
json!("#/definitions/ResponseItem")
);
let payload_ref = response_item["properties"]["payload"]["$ref"]
.as_str()
.expect("response item payload reference");
let payload = schema
.pointer(
payload_ref
.strip_prefix('#')
.expect("local schema reference"),
)
.expect("response item payload");
assert!(payload["properties"].get("id_allocation").is_some());
let compacted = &schema["definitions"]["CompactedItem"];
assert_eq!(
compacted["properties"]["replacement_history"]["items"]["$ref"],
json!("#/definitions/ResponseItem")
payload_ref
);
assert_eq!(
compacted["properties"]["replacement_history_metadata"]["items"]["$ref"],
@@ -1071,6 +1158,42 @@ fn compacted_item_serializes_window_number_and_id() -> Result<()> {
Ok(())
}
#[test]
fn structured_window_ids_round_trip_in_session_metadata_and_compaction() -> Result<()> {
let uuid = "019020ef-10ff-7000-8000-000000000001";
let meta: codex_protocol::protocol::SessionContextWindow =
serde_json::from_value(json!({"window_id": uuid}))?;
let id = meta.window_id.validated().expect("UUIDv7 window");
assert_eq!((id.to_string(), id.allocation()), (uuid.to_owned(), None));
let first = json!({"salt":"window","ordinal":0,"value":42});
let previous = json!({"salt":"window","ordinal":1,"value":456});
let current = json!({"salt":"window","ordinal":2,"value":7});
let header = json!({"window_id": first});
let meta: codex_protocol::protocol::SessionContextWindow =
serde_json::from_value(header.clone())?;
assert_eq!(meta.window_id.to_string(), "window042");
assert_eq!(serde_json::to_value(meta)?, header);
let item: CompactedItem = serde_json::from_value(json!({
"message":"summary", "window_number":2, "first_window_id":first,
"previous_window_id":previous, "window_id":current,
}))?;
let id = item.window_id.as_ref().unwrap().validated().unwrap();
assert_eq!(
(id.to_string(), id.allocation().unwrap().ordinal()),
("window007".to_string(), 2)
);
let wire = serde_json::to_value(item)?;
assert_eq!(
(
&wire["first_window_id"],
&wire["previous_window_id"],
&wire["window_id"]
),
(&first, &previous, &current)
);
Ok(())
}
#[test]
/// Keeps legacy numeric window IDs readable in stored compacted items.
fn compacted_item_migrates_legacy_numeric_window_id() -> Result<()> {
+1
View File
@@ -22,6 +22,7 @@ codex-http-client = { workspace = true }
codex-network-proxy = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-image = { workspace = true }
codex-utils-model-facing-id = { workspace = true }
codex-utils-path-uri = { workspace = true }
codex-utils-redacted-string = { workspace = true }
codex-utils-string = { workspace = true }
+31 -11
View File
@@ -1,37 +1,57 @@
//! Context window identity used for compaction, rollout restoration, and request metadata.
//! Rollouts store a UUIDv7 string or a window allocation. Request metadata uses its display form.
use codex_utils_model_facing_id::ModelFacingId;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::fmt;
use uuid::Uuid;
#[derive(derive_more::Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[debug("{_0}")]
#[serde(transparent)]
pub struct ContextWindowId(String);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum ContextWindowId {
Text(String),
Generated(Box<ModelFacingId>),
}
impl ContextWindowId {
pub fn new_uuid_v7() -> Self {
Self(Uuid::now_v7().to_string())
Self::Text(Uuid::now_v7().to_string())
}
/// Reads a persisted window ID, ignoring IDs outside the supported format.
pub fn allocation(&self) -> Option<&ModelFacingId> {
match self {
Self::Text(_) => None,
Self::Generated(id) => (id.salt() == "window").then_some(id),
}
}
/// Parses a UUIDv7 window ID from a rollout string.
pub fn parse(value: &str) -> Option<Self> {
Uuid::parse_str(value)
.ok()
.filter(|uuid| uuid.get_version_num() == 7)
.map(|uuid| Self(uuid.to_string()))
.map(|uuid| Self::Text(uuid.to_string()))
}
/// Validate a deserialized window ID before restoring it.
/// Restore the window without discarding its allocation ordinal.
pub fn validated(&self) -> Option<Self> {
Self::parse(&self.0)
match self {
Self::Text(text) => Self::parse(text),
Self::Generated(_) => self.allocation().map(|_| self.clone()),
}
}
}
impl fmt::Display for ContextWindowId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
match self {
Self::Text(text) => formatter.write_str(text),
Self::Generated(id) => id.fmt(formatter),
}
}
}
#[cfg(test)]
#[path = "context_window_id_tests.rs"]
mod tests;
@@ -0,0 +1,16 @@
#![cfg(test)]
use super::ContextWindowId;
use codex_utils_model_facing_id::ModelFacingIdGenerator;
use pretty_assertions::assert_eq;
#[test]
fn restores_allocations_from_the_window_generator() {
let window = ContextWindowId::Generated(Box::new(
ModelFacingIdGenerator::new(/*seed*/ 0, "window").fetch_next(),
));
assert_eq!(window.validated(), Some(window));
let item = ModelFacingIdGenerator::new(/*seed*/ 0, "item").fetch_next();
assert_eq!(ContextWindowId::Generated(Box::new(item)).validated(), None);
}
+1
View File
@@ -17,6 +17,7 @@ use item_id_generator::uuid_v7_item_id_generator_for_test;
pub use permission_profile_intersection::PermissionIntersectionError;
pub use permission_profile_intersection::intersect_effective_permission_profiles;
pub use response_item_id::ResponseItemId;
pub use response_item_id::ResponseItemIdAllocation;
pub use response_usage::ResponseUsageMetadata;
pub use sanitized_git_url::SanitizedGitUrl;
pub use session_id::SessionId;
+31 -15
View File
@@ -31,6 +31,7 @@ use schemars::JsonSchema;
use crate::ResponseItemId;
use crate::mcp::CallToolResult;
use crate::response_item_id::OptionalWireItemId;
use codex_utils_path_uri::PathUri;
mod configuration_update;
@@ -1096,7 +1097,8 @@ impl InternalChatMessageMetadataPassthrough {
pub enum ResponseItem {
AdditionalTools {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
role: String,
#[ts(type = "unknown[]")]
@@ -1111,7 +1113,8 @@ pub enum ResponseItem {
#[ts(optional)]
status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
role: String,
content: Vec<ContentItem>,
@@ -1127,7 +1130,8 @@ pub enum ResponseItem {
},
AgentMessage {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
author: String,
recipient: String,
@@ -1138,7 +1142,8 @@ pub enum ResponseItem {
},
Reasoning {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
summary: Vec<ReasoningItemReasoningSummary>,
#[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
@@ -1155,7 +1160,8 @@ pub enum ResponseItem {
encrypted_content: Option<String>,
/// Legacy id field retained for compatibility with older payloads.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
/// Set when using the Responses API.
call_id: Option<String>,
@@ -1173,7 +1179,8 @@ pub enum ResponseItem {
#[ts(optional)]
status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1196,7 +1203,8 @@ pub enum ResponseItem {
#[ts(optional)]
encrypted_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1216,7 +1224,8 @@ pub enum ResponseItem {
// We keep this behavior centralized in `FunctionCallOutputPayload`.
FunctionCallOutput {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -1239,7 +1248,8 @@ pub enum ResponseItem {
#[ts(optional)]
encrypted_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -1260,7 +1270,8 @@ pub enum ResponseItem {
// text or structured content items.
CustomToolCallOutput {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1275,7 +1286,8 @@ pub enum ResponseItem {
},
ToolSearchOutput {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
call_id: Option<String>,
status: String,
@@ -1299,7 +1311,8 @@ pub enum ResponseItem {
#[ts(optional)]
encrypted_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -1325,7 +1338,8 @@ pub enum ResponseItem {
#[ts(optional)]
encrypted_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1339,7 +1353,8 @@ pub enum ResponseItem {
#[serde(alias = "compaction_summary")]
Compaction {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
encrypted_content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1354,7 +1369,8 @@ pub enum ResponseItem {
CompactionTrigger {},
ContextCompaction {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
id: Option<ResponseItemId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
+4 -2
View File
@@ -61,6 +61,7 @@ use crate::plan_tool::UpdatePlanArgs;
use crate::request_permissions::RequestPermissionsEvent;
use crate::request_permissions::RequestPermissionsResponse;
use crate::request_user_input::RequestUserInputResponse;
use crate::response_item_id::OptionalWireItemId;
use crate::turn_input::CyberAccessProgram;
use crate::turn_input::SuspendTurnOutcome;
use crate::turn_input::TurnInputMode;
@@ -994,7 +995,8 @@ impl FromStr for ThreadHistoryMode {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
pub struct InterAgentCommunication {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[serde(with = "OptionalWireItemId")]
#[ts(optional, as = "Option<ResponseItemId>")]
pub id: Option<ResponseItemId>,
pub author: AgentPath,
pub recipient: AgentPath,
@@ -3450,7 +3452,7 @@ pub enum MultiAgentVersion {
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct SessionContextWindow {
/// UUIDv7 identity of this context window.
/// Persisted model-facing window identity.
pub window_id: crate::ContextWindowId,
}
+119 -11
View File
@@ -1,6 +1,10 @@
use std::cmp::Ordering;
use std::fmt;
use std::hash::Hash;
use std::hash::Hasher;
use std::ops::Deref;
use codex_utils_model_facing_id::ModelFacingId;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -8,17 +12,85 @@ use ts_rs::TS;
/// A Responses API item ID. New IDs require an explicit prefix; deserialization
/// remains permissive so legacy rollouts can still be read.
#[derive(
Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, TS,
)]
#[derive(Clone, Serialize, Deserialize, JsonSchema, TS)]
#[serde(untagged)]
pub enum ResponseItemId {
Text(String),
// TypeScript describes the API, where both variants are strings.
#[ts(skip)]
Generated {
id: String,
id_allocation: Box<ResponseItemIdAllocation>,
},
}
/// The generator allocation and the Responses-specific parts of a structured item ID.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ResponseItemIdAllocation {
/// Item kind, e.g. `msg` in `msg_item042`. Bare request reservations omit it.
#[serde(skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
#[serde(flatten)]
pub model_id: ModelFacingId,
/// Provider-assigned tail, including its separator.
#[serde(skip_serializing_if = "Option::is_none")]
pub suffix: Option<String>,
}
/// Responses and raw item events exchange optional string IDs.
#[derive(Deserialize, JsonSchema)]
#[serde(transparent)]
#[schemars(with = "String")]
#[ts(type = "string")]
pub struct ResponseItemId(String);
pub(crate) struct OptionalWireItemId(Option<String>);
impl OptionalWireItemId {
pub(crate) fn serialize<S: serde::Serializer>(
id: &Option<ResponseItemId>,
serializer: S,
) -> Result<S::Ok, S::Error> {
id.as_deref().serialize(serializer)
}
pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<ResponseItemId>, D::Error> {
Ok(<Self as Deserialize>::deserialize(deserializer)?
.0
.map(ResponseItemId::from_server))
}
}
impl ResponseItemId {
pub fn with_suffix(prefix: &str, suffix: impl fmt::Display) -> Self {
Self(format!("{prefix}_{suffix}"))
Self::Text(format!("{prefix}_{suffix}"))
}
/// Keep the allocation that produced this ID while rendering a string on the Responses wire.
pub fn generated(kind: &str, model_id: ModelFacingId) -> Self {
let (prefix, id) = if kind == model_id.salt() {
(None, model_id.to_string())
} else {
(Some(kind.to_owned()), format!("{kind}_{model_id}"))
};
Self::Text(id).with_allocation(ResponseItemIdAllocation {
prefix,
model_id,
suffix: None,
})
}
/// Restore a known allocation while keeping the exact wire ID.
pub fn with_allocation(self, allocation: ResponseItemIdAllocation) -> Self {
Self::Generated {
id: self.into(),
id_allocation: Box::new(allocation),
}
}
pub fn allocation(&self) -> Option<&ResponseItemIdAllocation> {
match self {
Self::Text(_) => None,
Self::Generated { id_allocation, .. } => Some(id_allocation),
}
}
/// Reserved for physical user inputs admitted by the host. Raw item
@@ -28,15 +100,17 @@ impl ResponseItemId {
}
pub fn is_admitted_user_input(&self) -> bool {
self.0.starts_with("msg_input_")
self.starts_with("msg_input_")
}
pub fn from_server(value: String) -> Self {
Self(value)
Self::Text(value)
}
pub fn as_str(&self) -> &str {
&self.0
match self {
Self::Text(id) | Self::Generated { id, .. } => id,
}
}
pub fn is_prefixed(&self) -> bool {
@@ -45,6 +119,29 @@ impl ResponseItemId {
}
}
// Lookups by a wire ID must also find an item restored with allocation provenance.
impl PartialEq for ResponseItemId {
fn eq(&self, other: &Self) -> bool {
self.as_str() == other.as_str()
}
}
impl Eq for ResponseItemId {}
impl Hash for ResponseItemId {
fn hash<H: Hasher>(&self, state: &mut H) {
self.as_str().hash(state);
}
}
impl Ord for ResponseItemId {
fn cmp(&self, other: &Self) -> Ordering {
self.as_str().cmp(other.as_str())
}
}
impl PartialOrd for ResponseItemId {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Deref for ResponseItemId {
type Target = str;
@@ -65,9 +162,20 @@ impl fmt::Display for ResponseItemId {
}
}
impl fmt::Debug for ResponseItemId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("ResponseItemId")
.field(&self.as_str())
.finish()
}
}
impl From<ResponseItemId> for String {
fn from(value: ResponseItemId) -> Self {
value.0
match value {
ResponseItemId::Text(id) | ResponseItemId::Generated { id, .. } => id,
}
}
}
@@ -1,7 +1,9 @@
use pretty_assertions::assert_eq;
use serde_json::json;
use uuid::Uuid;
use super::ResponseItemId;
use crate::models::ResponseItem;
#[test]
fn creates_prefixed_uuid_v7_ids() {
@@ -37,6 +39,31 @@ fn deserializes_arbitrary_ids_as_strings() {
);
}
#[test]
fn structured_ids_round_trip_but_raw_items_only_accept_strings() {
let model_id = serde_json::from_value(json!({"salt":"item","ordinal":7,"value":42})).unwrap();
let id = ResponseItemId::generated("msg", model_id);
let mut stored = serde_json::to_value(id).unwrap();
assert_eq!(
stored,
json!({"id":"msg_item042","id_allocation":{
"prefix":"msg","salt":"item","ordinal":7,"value":42
}})
);
stored["id"] = json!("msg_item042_2");
stored["id_allocation"]["suffix"] = json!("_2");
let id: ResponseItemId = serde_json::from_value(stored.clone()).unwrap();
assert_eq!(id.as_str(), "msg_item042_2");
assert_eq!(serde_json::to_value(&id).unwrap(), stored);
let mut raw = json!({"type": "message", "role": "assistant", "content": [], "id": stored});
assert!(serde_json::from_value::<ResponseItem>(raw.clone()).is_err());
raw["id"] = json!(id.as_str());
let item: ResponseItem = serde_json::from_value(raw.clone()).unwrap();
assert_eq!(item.id().unwrap().allocation(), None);
assert_eq!(serde_json::to_value(item).unwrap(), raw);
}
#[test]
fn recognizes_non_empty_prefix_and_suffix() {
for (value, expected) in [