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
+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 [