diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 19a1146dc5..80ab9016a6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 272fa60a2a..baaefe8d3b 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -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" } diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 8e995c180a..29094221fa 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/core/src/responses_metadata.rs b/codex-rs/core/src/responses_metadata.rs index 8f886779c0..795cbf1eca 100644 --- a/codex-rs/core/src/responses_metadata.rs +++ b/codex-rs/core/src/responses_metadata.rs @@ -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, #[serde(default, skip_serializing_if = "Option::is_none")] - context_window_id: Option<&'a ContextWindowId>, + context_window_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] request_kind: Option<&'static str>, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/history/src/guardian_history.rs b/codex-rs/history/src/guardian_history.rs index e0657a3061..e077afdf27 100644 --- a/codex-rs/history/src/guardian_history.rs +++ b/codex-rs/history/src/guardian_history.rs @@ -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); #[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>, } @@ -35,7 +36,7 @@ struct Entry<'a> { impl Serialize for GuardianHistoryCheckpoint { fn serialize(&self, serializer: S) -> Result { 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::>::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(), diff --git a/codex-rs/history/src/rollout_payload.rs b/codex-rs/history/src/rollout_payload.rs index e7bf15f0fd..869195968b 100644 --- a/codex-rs/history/src/rollout_payload.rs +++ b/codex-rs/history/src/rollout_payload.rs @@ -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>, }, 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>, +} + +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> 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> 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> 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>>, + replacement_history: Option>>, #[serde(default, skip_serializing_if = "Option::is_none")] replacement_history_metadata: Option>>, #[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> 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> 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(_)) => { diff --git a/codex-rs/history/src/tests.rs b/codex-rs/history/src/tests.rs index 3e929d3926..670388dc68 100644 --- a/codex-rs/history/src/tests.rs +++ b/codex-rs/history/src/tests.rs @@ -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<()> { diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 8314592a7d..030fdd3ed7 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -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 } diff --git a/codex-rs/protocol/src/context_window_id.rs b/codex-rs/protocol/src/context_window_id.rs index 115439ccd7..60550bfe29 100644 --- a/codex-rs/protocol/src/context_window_id.rs +++ b/codex-rs/protocol/src/context_window_id.rs @@ -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), +} 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 { 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::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; diff --git a/codex-rs/protocol/src/context_window_id_tests.rs b/codex-rs/protocol/src/context_window_id_tests.rs new file mode 100644 index 0000000000..b57f50226c --- /dev/null +++ b/codex-rs/protocol/src/context_window_id_tests.rs @@ -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); +} diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 8c7442499d..3c725f1c5d 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -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; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 13f1eb8a1e..dfe2008a5f 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -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")] id: Option, role: String, #[ts(type = "unknown[]")] @@ -1111,7 +1113,8 @@ pub enum ResponseItem { #[ts(optional)] status: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] + #[serde(with = "OptionalWireItemId")] + #[ts(optional, as = "Option")] id: Option, role: String, content: Vec, @@ -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")] id: Option, 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")] id: Option, summary: Vec, #[serde(default, skip_serializing_if = "should_serialize_reasoning_content")] @@ -1155,7 +1160,8 @@ pub enum ResponseItem { encrypted_content: Option, /// 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")] id: Option, /// Set when using the Responses API. call_id: Option, @@ -1173,7 +1179,8 @@ pub enum ResponseItem { #[ts(optional)] status: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] + #[serde(with = "OptionalWireItemId")] + #[ts(optional, as = "Option")] id: Option, name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1196,7 +1203,8 @@ pub enum ResponseItem { #[ts(optional)] encrypted_content: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] + #[serde(with = "OptionalWireItemId")] + #[ts(optional, as = "Option")] id: Option, call_id: Option, #[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")] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1239,7 +1248,8 @@ pub enum ResponseItem { #[ts(optional)] encrypted_content: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] + #[serde(with = "OptionalWireItemId")] + #[ts(optional, as = "Option")] id: Option, #[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")] id: Option, 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")] id: Option, call_id: Option, status: String, @@ -1299,7 +1311,8 @@ pub enum ResponseItem { #[ts(optional)] encrypted_content: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] + #[serde(with = "OptionalWireItemId")] + #[ts(optional, as = "Option")] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1325,7 +1338,8 @@ pub enum ResponseItem { #[ts(optional)] encrypted_content: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] + #[serde(with = "OptionalWireItemId")] + #[ts(optional, as = "Option")] id: Option, 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")] id: Option, 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")] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index dedf79e3bc..18c29a465c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -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")] pub id: Option, 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, } diff --git a/codex-rs/protocol/src/response_item_id.rs b/codex-rs/protocol/src/response_item_id.rs index 18c854e6d8..289091e486 100644 --- a/codex-rs/protocol/src/response_item_id.rs +++ b/codex-rs/protocol/src/response_item_id.rs @@ -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, + }, +} + +/// 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, + #[serde(flatten)] + pub model_id: ModelFacingId, + /// Provider-assigned tail, including its separator. + #[serde(skip_serializing_if = "Option::is_none")] + pub suffix: Option, +} + +/// 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); + +impl OptionalWireItemId { + pub(crate) fn serialize( + id: &Option, + serializer: S, + ) -> Result { + id.as_deref().serialize(serializer) + } + + pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + Ok(::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(&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 { + 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 for String { fn from(value: ResponseItemId) -> Self { - value.0 + match value { + ResponseItemId::Text(id) | ResponseItemId::Generated { id, .. } => id, + } } } diff --git a/codex-rs/protocol/src/response_item_id_tests.rs b/codex-rs/protocol/src/response_item_id_tests.rs index 2a7f17d170..2fb74fecbe 100644 --- a/codex-rs/protocol/src/response_item_id_tests.rs +++ b/codex-rs/protocol/src/response_item_id_tests.rs @@ -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::(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 [