mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
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:
committed by
copyberry
parent
32234ddac4
commit
340a18ed7c
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
BIN
Binary file not shown.
@@ -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")]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(_)) => {
|
||||
|
||||
@@ -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, ¤t)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Keeps legacy numeric window IDs readable in stored compacted items.
|
||||
fn compacted_item_migrates_legacy_numeric_window_id() -> Result<()> {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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)]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user