From c9d52de5ca52a6b4439e7c0f69b34f6331926bb4 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 11 Jul 2026 02:24:09 +0000 Subject: [PATCH] Require prefixes for outbound response item IDs (#32312) ## What changed - Add a `ResponseItemId` type that generates item-specific prefixes with UUIDv7 suffixes and use it across response items. - Keep deserialization permissive for legacy histories, but omit empty or unprefixed item IDs from HTTP and WebSocket requests. - Export the new type in the generated TypeScript protocol schema. ## Testing - Cover prefixed ID generation, legacy deserialization, prefix recognition, and outbound request filtering. GitOrigin-RevId: 0209fe430c826d0ae88bc4648652c4ebf390c4a4 --- .../schema/typescript/ResponseItem.ts | 7 +- .../schema/typescript/ResponseItemId.ts | 9 ++ .../schema/typescript/index.ts | 1 + .../src/protocol/thread_history.rs | 4 +- .../src/endpoint/responses_websocket.rs | 3 +- codex-rs/codex-api/src/endpoint/search.rs | 3 +- codex-rs/codex-api/tests/clients.rs | 5 +- codex-rs/core/src/agent/control_tests.rs | 3 +- codex-rs/core/src/client.rs | 6 ++ codex-rs/core/src/client_tests.rs | 6 +- codex-rs/core/src/compact_remote.rs | 3 +- codex-rs/core/src/compact_tests.rs | 5 +- .../src/context/contextual_user_message.rs | 2 +- .../core/src/context_manager/history_tests.rs | 13 +-- .../core/src/context_manager/normalize.rs | 9 +- codex-rs/core/src/event_mapping.rs | 16 ++-- codex-rs/core/src/event_mapping_tests.rs | 21 ++--- codex-rs/core/src/session/mod.rs | 28 ++---- codex-rs/core/src/session/tests.rs | 23 +++-- codex-rs/core/src/session/turn.rs | 7 +- codex-rs/core/src/session/turn_tests.rs | 3 +- codex-rs/core/src/stream_events_utils.rs | 7 +- .../core/src/stream_events_utils_tests.rs | 3 +- codex-rs/core/src/tasks/review.rs | 5 +- codex-rs/core/src/thread_manager_tests.rs | 3 +- .../src/thread_rollout_truncation_tests.rs | 3 +- codex-rs/core/tests/suite/agent_websocket.rs | 16 ++-- codex-rs/core/tests/suite/client.rs | 51 +++++++---- .../core/tests/suite/client_websockets.rs | 90 +++++++++++++++---- codex-rs/ext/image-generation/src/tests.rs | 3 +- codex-rs/ext/web-search/src/history.rs | 8 +- codex-rs/protocol/src/items.rs | 12 +-- codex-rs/protocol/src/lib.rs | 2 + codex-rs/protocol/src/models.rs | 77 ++++++++++------ codex-rs/protocol/src/protocol.rs | 5 +- codex-rs/protocol/src/response_item_id.rs | 70 +++++++++++++++ .../protocol/src/response_item_id_tests.rs | 53 +++++++++++ codex-rs/rollout-trace/src/inference.rs | 5 +- 38 files changed, 430 insertions(+), 160 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/ResponseItemId.ts create mode 100644 codex-rs/protocol/src/response_item_id.rs create mode 100644 codex-rs/protocol/src/response_item_id_tests.rs diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 769b1b08fc83..2758941b6d3a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -10,14 +10,15 @@ import type { LocalShellStatus } from "./LocalShellStatus"; import type { MessagePhase } from "./MessagePhase"; import type { ReasoningItemContent } from "./ReasoningItemContent"; import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; +import type { ResponseItemId } from "./ResponseItemId"; import type { WebSearchAction } from "./WebSearchAction"; -export type ResponseItem = { "type": "message", id?: string, role: string, content: Array, phase?: MessagePhase, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "agent_message", id?: string, author: string, recipient: string, content: Array, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "reasoning", id?: string, summary: Array, content?: Array, encrypted_content: string | null, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "local_shell_call", +export type ResponseItem = { "type": "message", id?: ResponseItemId, role: string, content: Array, phase?: MessagePhase, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "agent_message", id?: ResponseItemId, author: string, recipient: string, content: Array, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "reasoning", id?: ResponseItemId, summary: Array, content?: Array, encrypted_content: string | null, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "local_shell_call", /** * Legacy id field retained for compatibility with older payloads. */ -id?: string, +id?: ResponseItemId, /** * Set when using the Responses API. */ -call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, namespace?: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: string, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: string, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; +call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: ResponseItemId, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: ResponseItemId, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: ResponseItemId, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: ResponseItemId, status?: string, call_id: string, name: string, namespace?: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: ResponseItemId, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: ResponseItemId, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: ResponseItemId, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: ResponseItemId, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: ResponseItemId, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: ResponseItemId, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItemId.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItemId.ts new file mode 100644 index 000000000000..c4f17ec5f854 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItemId.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A Responses API item ID. New IDs require an explicit prefix; deserialization + * remains permissive so legacy rollouts can still be read. + */ +export type ResponseItemId = string; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 6237fb44489b..a30ee85b101e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -70,6 +70,7 @@ export type { Resource } from "./Resource"; export type { ResourceContent } from "./ResourceContent"; export type { ResourceTemplate } from "./ResourceTemplate"; export type { ResponseItem } from "./ResponseItem"; +export type { ResponseItemId } from "./ResponseItemId"; export type { ReviewDecision } from "./ReviewDecision"; export type { ServerNotification } from "./ServerNotification"; export type { ServerRequest } from "./ServerRequest"; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 8e32e9c1b55f..2c6720f98a0a 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -447,7 +447,7 @@ impl ThreadHistoryBuilder { return; } - let Some(hook_prompt) = parse_hook_prompt_message(id.as_ref(), content) else { + let Some(hook_prompt) = parse_hook_prompt_message(id.as_deref(), content) else { return; }; @@ -4113,7 +4113,7 @@ mod tests { collaboration_mode_kind: Default::default(), })), RolloutItem::ResponseItem(codex_protocol::models::ResponseItem::Message { - id: Some("msg-1".into()), + id: Some(codex_protocol::ResponseItemId::with_suffix("msg", "1")), role: "user".into(), content: vec![codex_protocol::models::ContentItem::InputText { text: "plain text".into(), diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 2eeb91bd6667..369179665cb3 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -892,6 +892,7 @@ fn serialize_websocket_request(request: &ResponsesWsRequest) -> Result Result<()> { model: "gpt-test".into(), instructions: "Say hi".into(), input: vec![ResponseItem::Message { - id: Some("msg_1".into()), + id: Some(ResponseItemId::with_suffix("msg", "1")), role: "user".into(), content: vec![ContentItem::InputText { text: "hi".into() }], phase: None, @@ -516,7 +517,7 @@ async fn azure_store_sends_ids_and_headers() -> Result<()> { model: "gpt-test".into(), instructions: "Say hi".into(), input: vec![ResponseItem::Message { - id: Some("msg_1".into()), + id: Some(ResponseItemId::with_suffix("msg", "1")), role: "user".into(), content: vec![ContentItem::InputText { text: "hi".into() }], phase: None, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 19b7c010a721..b5b3262fa2b2 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -19,6 +19,7 @@ use codex_features::Feature; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_protocol::AgentPath; +use codex_protocol::ResponseItemId; use codex_protocol::capabilities::CapabilityRootLocation; use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::config_types::ModeKind; @@ -979,7 +980,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), assistant_message("parent unknown phase", /*phase*/ None), ResponseItem::Reasoning { - id: Some("parent-reasoning".to_string()), + id: Some(ResponseItemId::with_suffix("rs", "parent-reasoning")), summary: Vec::new(), content: None, encrypted_content: None, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index b0be0386b7a4..9676e6ef5fe9 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -908,6 +908,12 @@ impl ModelClient { } fn prepare_response_items_for_request(&self, input: &mut [ResponseItem], store: bool) { + for item in input.iter_mut() { + if item.id().is_some_and(|id| !id.is_prefixed()) { + item.set_id(/*new_id*/ None); + } + } + if self.state.item_ids_enabled || store { return; } diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 885e1da48e65..3f33aff1a584 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -414,7 +414,7 @@ fn started_inference_attempt(temp: &TempDir) -> anyhow::Result ResponseItem { ResponseItem::Message { - id: Some(id.to_string()), + id: Some(codex_protocol::ResponseItemId::with_suffix("msg", id)), role: "assistant".to_string(), content: vec![ContentItem::OutputText { text: text.to_string(), @@ -581,7 +581,7 @@ async fn dropped_response_stream_traces_cancelled_partial_output() -> anyhow::Re // response.completed event. The harness has enough information to keep this // item in history, so the trace should preserve it when the stream is // abandoned. - let item = output_message("msg-1", "partial answer"); + let item = output_message("1", "partial answer"); let api_stream = futures::stream::iter([Ok(ResponseEvent::OutputItemDone(item))]) .chain(futures::stream::pending()); let (mut stream, _) = super::map_response_events( @@ -699,7 +699,7 @@ async fn dropped_backpressured_response_stream_traces_cancelled_partial_output() events.push_back(ResponseEvent::Created); } events.push_back(ResponseEvent::OutputItemDone(output_message( - "msg-1", + "1", "partial answer", ))); let api_stream = NotifyAfterEventStream { diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 4b1db9355544..a7e74d33ee33 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -434,13 +434,14 @@ fn rewritten_output_for_context_window(item: &ResponseItem) -> Option ResponseItem::ToolSearchOutput { - id: item.id().map(str::to_string), + id: id.clone(), call_id: call_id.clone(), status: status.clone(), execution: execution.clone(), diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index 16b9ae16f6e2..35cfd2574fb3 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::session::tests::build_world_state_from_turn_context; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; +use codex_protocol::ResponseItemId; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::InternalChatMessageMetadataPassthrough; use pretty_assertions::assert_eq; @@ -85,7 +86,7 @@ fn content_items_to_text_ignores_image_only_content() { fn collect_user_messages_extracts_user_text_only() { let items = vec![ ResponseItem::Message { - id: Some("assistant".to_string()), + id: Some(ResponseItemId::with_suffix("msg", "assistant")), role: "assistant".to_string(), content: vec![ContentItem::OutputText { text: "ignored".to_string(), @@ -94,7 +95,7 @@ fn collect_user_messages_extracts_user_text_only() { internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { - id: Some("user".to_string()), + id: Some(ResponseItemId::with_suffix("msg", "user")), role: "user".to_string(), content: vec![ContentItem::InputText { text: "first".to_string(), diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index 390a20fd4191..a978a6f12961 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -75,7 +75,7 @@ pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool { } pub(crate) fn parse_visible_hook_prompt_message( - id: Option<&String>, + id: Option<&str>, content: &[ContentItem], ) -> Option { let mut fragments = Vec::new(); diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 68b3134810b0..ccd32bb0e313 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -5,6 +5,7 @@ use crate::context::world_state::WorldStateSection; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::AgentPath; +use codex_protocol::ResponseItemId; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; @@ -639,7 +640,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { let history = create_history_with_items(vec![ ResponseItem::ImageGenerationCall { - id: Some("ig_123".to_string()), + id: Some(ResponseItemId::with_suffix("ig", "123")), status: "generating".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), @@ -660,7 +661,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { history.for_prompt(&default_input_modalities()), vec![ ResponseItem::ImageGenerationCall { - id: Some("ig_123".to_string()), + id: Some(ResponseItemId::with_suffix("ig", "123")), status: "generating".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), @@ -692,7 +693,7 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { internal_chat_message_metadata_passthrough: None, }, ResponseItem::ImageGenerationCall { - id: Some("ig_123".to_string()), + id: Some(ResponseItemId::with_suffix("ig", "123")), status: "completed".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), @@ -713,7 +714,7 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { internal_chat_message_metadata_passthrough: None, }, ResponseItem::ImageGenerationCall { - id: Some("ig_123".to_string()), + id: Some(ResponseItemId::with_suffix("ig", "123")), status: "completed".to_string(), revised_prompt: Some("lobster".to_string()), result: String::new(), @@ -1642,7 +1643,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { fn for_prompt_assigns_stable_id_to_synthetic_output_without_reordering_history() { let items = vec![ ResponseItem::FunctionCall { - id: Some("fc_existing".to_string()), + id: Some(ResponseItemId::with_suffix("fc", "existing")), name: "do_it".to_string(), namespace: None, arguments: "{}".to_string(), @@ -1650,7 +1651,7 @@ fn for_prompt_assigns_stable_id_to_synthetic_output_without_reordering_history() internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { - id: Some("msg_later".to_string()), + id: Some(ResponseItemId::with_suffix("msg", "later")), role: "user".to_string(), content: vec![ContentItem::InputText { text: "later turn".to_string(), diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index 3fd1a59a8f07..35230301961f 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -1,3 +1,4 @@ +use codex_protocol::ResponseItemId; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; @@ -132,12 +133,12 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { /// outputs, so the namespace and name format must remain stable across retries /// and resumes to preserve prompt-cache reuse. Returning `None` when the source /// call has no ID preserves the legacy behavior for older history items. -fn synthetic_output_id(prefix: &str, item_id: Option<&str>) -> Option { +fn synthetic_output_id(prefix: &str, item_id: Option<&str>) -> Option { let source_id = item_id.filter(|id| !id.is_empty())?; let name = format!("{prefix}:{source_id}"); - Some(format!( - "{prefix}_{}", - Uuid::new_v5(&SYNTHETIC_OUTPUT_ID_NAMESPACE, name.as_bytes()) + Some(ResponseItemId::with_suffix( + prefix, + Uuid::new_v5(&SYNTHETIC_OUTPUT_ID_NAMESPACE, name.as_bytes()), )) } diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index ef8f0fad5b4a..44c804f94836 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -121,7 +121,7 @@ fn parse_user_message(message: &[ContentItem]) -> Option { } fn parse_agent_message( - id: Option<&String>, + id: Option<&str>, message: &[ContentItem], phase: Option, ) -> AgentMessageItem { @@ -139,7 +139,9 @@ fn parse_agent_message( } } } - let id = id.cloned().unwrap_or_else(|| Uuid::new_v4().to_string()); + let id = id + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()); AgentMessageItem { id, content, @@ -157,11 +159,11 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { phase, .. } => match role.as_str() { - "user" => parse_visible_hook_prompt_message(id.as_ref(), content) + "user" => parse_visible_hook_prompt_message(id.as_deref(), content) .map(TurnItem::HookPrompt) .or_else(|| parse_user_message(content).map(TurnItem::UserMessage)), "assistant" => Some(TurnItem::AgentMessage(parse_agent_message( - id.as_ref(), + id.as_deref(), content, phase.clone(), ))), @@ -190,7 +192,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { }) .collect(); Some(TurnItem::Reasoning(ReasoningItem { - id: id.clone().unwrap_or_default(), + id: id.as_deref().unwrap_or_default().to_string(), summary_text, raw_content, })) @@ -201,7 +203,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { None => (WebSearchAction::Other, String::new()), }; Some(TurnItem::WebSearch(WebSearchItem { - id: id.clone().unwrap_or_default(), + id: id.as_deref().unwrap_or_default().to_string(), query, action, })) @@ -214,7 +216,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { .. } => Some(TurnItem::ImageGeneration( codex_protocol::items::ImageGenerationItem { - id: id.clone()?, + id: id.as_deref()?.to_string(), status: status.clone(), revised_prompt: revised_prompt.clone(), result: result.clone(), diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index c91034d4518a..ab4077f149f4 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -4,6 +4,7 @@ use super::parse_turn_item; use crate::context::ContextualUserFragment; use crate::context::InternalContextSource; use crate::context::InternalModelContextFragment; +use codex_protocol::ResponseItemId; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::TurnItem; @@ -342,7 +343,7 @@ fn parses_hook_prompt_message_as_distinct_turn_item() { #[test] fn parses_hook_prompt_and_hides_other_contextual_fragments() { let item = ResponseItem::Message { - id: Some("msg-1".to_string()), + id: Some(ResponseItemId::with_suffix("msg", "1")), role: "user".to_string(), content: vec![ ContentItem::InputText { @@ -361,7 +362,7 @@ fn parses_hook_prompt_and_hides_other_contextual_fragments() { match turn_item { TurnItem::HookPrompt(hook_prompt) => { - assert_eq!(hook_prompt.id, "msg-1"); + assert_eq!(hook_prompt.id, "msg_1"); assert_eq!( hook_prompt.fragments, vec![HookPromptFragment { @@ -377,7 +378,7 @@ fn parses_hook_prompt_and_hides_other_contextual_fragments() { #[test] fn internal_model_context_does_not_parse_as_visible_turn_item() { let item = ResponseItem::Message { - id: Some("msg-1".to_string()), + id: Some(ResponseItemId::with_suffix("msg", "1")), role: "user".to_string(), content: vec![ContentItem::InputText { text: InternalModelContextFragment::new( @@ -396,7 +397,7 @@ fn internal_model_context_does_not_parse_as_visible_turn_item() { #[test] fn parses_agent_message() { let item = ResponseItem::Message { - id: Some("msg-1".to_string()), + id: Some(ResponseItemId::with_suffix("msg", "1")), role: "assistant".to_string(), content: vec![ContentItem::OutputText { text: "Hello from Codex".to_string(), @@ -421,7 +422,7 @@ fn parses_agent_message() { #[test] fn parses_reasoning_summary_and_raw_content() { let item = ResponseItem::Reasoning { - id: Some("reasoning_1".to_string()), + id: Some(ResponseItemId::with_suffix("rs", "1")), summary: vec![ ReasoningItemReasoningSummary::SummaryText { text: "Step 1".to_string(), @@ -454,7 +455,7 @@ fn parses_reasoning_summary_and_raw_content() { #[test] fn parses_reasoning_including_raw_content() { let item = ResponseItem::Reasoning { - id: Some("reasoning_2".to_string()), + id: Some(ResponseItemId::with_suffix("rs", "2")), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "Summarized step".to_string(), }], @@ -487,7 +488,7 @@ fn parses_reasoning_including_raw_content() { #[test] fn parses_web_search_call() { let item = ResponseItem::WebSearchCall { - id: Some("ws_1".to_string()), + id: Some(ResponseItemId::with_suffix("ws", "1")), status: Some("completed".to_string()), action: Some(WebSearchAction::Search { query: Some("weather".to_string()), @@ -517,7 +518,7 @@ fn parses_web_search_call() { #[test] fn parses_web_search_open_page_call() { let item = ResponseItem::WebSearchCall { - id: Some("ws_open".to_string()), + id: Some(ResponseItemId::with_suffix("ws", "open")), status: Some("completed".to_string()), action: Some(WebSearchAction::OpenPage { url: Some("https://example.com".to_string()), @@ -545,7 +546,7 @@ fn parses_web_search_open_page_call() { #[test] fn parses_web_search_find_in_page_call() { let item = ResponseItem::WebSearchCall { - id: Some("ws_find".to_string()), + id: Some(ResponseItemId::with_suffix("ws", "find")), status: Some("completed".to_string()), action: Some(WebSearchAction::FindInPage { url: Some("https://example.com".to_string()), @@ -575,7 +576,7 @@ fn parses_web_search_find_in_page_call() { #[test] fn parses_partial_web_search_call_without_action_as_other() { let item = ResponseItem::WebSearchCall { - id: Some("ws_partial".to_string()), + id: Some(ResponseItemId::with_suffix("ws", "partial")), status: Some("in_progress".to_string()), action: None, internal_chat_message_metadata_passthrough: None, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 516116e04026..97e5ef386550 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -337,6 +337,7 @@ use codex_mcp::effective_mcp_servers; use codex_otel::SessionTelemetry; use codex_otel::THREAD_STARTED_METRIC; use codex_otel::TelemetryAuthMode; +use codex_protocol::ResponseItemId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -2784,7 +2785,10 @@ impl Session { } fn assign_missing_response_item_ids(items: Cow<'_, [ResponseItem]>) -> Cow<'_, [ResponseItem]> { - if items.iter().all(|item| item.id().is_some()) { + if items + .iter() + .all(|item| item.id().is_some_and(|id| !id.is_empty())) + { return items; } let mut items = items; @@ -2795,27 +2799,13 @@ impl Session { } fn assign_missing_response_item_id(item: &mut ResponseItem) { - if item.id().is_some() { + if item.id().is_some_and(|id| !id.is_empty()) { return; } - let prefix = match item { - ResponseItem::AdditionalTools { .. } => "at", - ResponseItem::Message { .. } => "msg", - ResponseItem::Reasoning { .. } => "rs", - ResponseItem::LocalShellCall { .. } => "lsh", - ResponseItem::FunctionCall { .. } => "fc", - ResponseItem::ToolSearchCall { .. } => "tsc", - ResponseItem::FunctionCallOutput { .. } => "fco", - ResponseItem::CustomToolCall { .. } => "ctc", - ResponseItem::CustomToolCallOutput { .. } => "ctco", - ResponseItem::ToolSearchOutput { .. } => "tso", - ResponseItem::WebSearchCall { .. } => "ws", - ResponseItem::ImageGenerationCall { .. } => "ig", - ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => "cmp", - ResponseItem::AgentMessage { .. } => "amsg", - ResponseItem::CompactionTrigger { .. } | ResponseItem::Other => return, + let Some(prefix) = item.id_prefix() else { + return; }; - item.set_id(Some(format!("{prefix}_{}", Uuid::now_v7()))); + item.set_id(Some(ResponseItemId::new(prefix))); } pub(crate) fn response_item_from_user_input(&self, input: Vec) -> ResponseItem { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4121baee4035..b94c2964aeef 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -43,6 +43,7 @@ use codex_models_manager::model_info; use codex_models_manager::test_support::construct_model_info_offline_for_tests; use codex_models_manager::test_support::get_model_offline_for_tests; use codex_protocol::AgentPath; +use codex_protocol::ResponseItemId; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; @@ -1922,7 +1923,10 @@ async fn record_inter_agent_communication_preserves_item_id_in_rollout_and_resum RolloutItem::ResponseItem(item @ ResponseItem::AgentMessage { .. }) => item.id(), _ => None, }); - assert_eq!(persisted_item_id, Some(live_item_id.as_str())); + assert_eq!( + persisted_item_id.map(ResponseItemId::as_str), + Some(live_item_id.as_str()) + ); let (resumed_session, _resumed_turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( @@ -1940,7 +1944,10 @@ async fn record_inter_agent_communication_preserves_item_id_in_rollout_and_resum let [resumed_item] = resumed_history.raw_items() else { panic!("expected exactly one resumed history item"); }; - assert_eq!(resumed_item.id(), Some(live_item_id.as_str())); + assert_eq!( + resumed_item.id().map(ResponseItemId::as_str), + Some(live_item_id.as_str()) + ); } #[tokio::test] @@ -1982,15 +1989,14 @@ async fn prepares_image_failures_before_history_insertion() { let history = session.state.lock().await.clone_history(); let id = history.raw_items()[0] .id() - .expect("history item should have an ID") - .to_string(); + .expect("history item should have an ID"); let uuid = id .strip_prefix("fco_") .expect("function call output ID should have the Responses API prefix"); let parsed_id = Uuid::parse_str(uuid).expect("history item should have a UUID ID"); assert_eq!(parsed_id.get_version(), Some(uuid::Version::SortRand)); let expected = vec![ResponseItem::FunctionCallOutput { - id: Some(id), + id: Some(id.clone()), call_id: "call-1".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ @@ -2869,7 +2875,7 @@ async fn record_initial_history_assigns_and_persists_id_for_forked_response_item .expect("forked response item should have an id") .to_string(); assert!(live_item_id.starts_with("msg_")); - expected_item.set_id(Some(live_item_id.clone())); + expected_item.set_id(live_item.id().cloned()); assert_eq!(live_history.raw_items(), &[expected_item]); session.flush_rollout().await.expect("rollout should flush"); @@ -2889,7 +2895,10 @@ async fn record_initial_history_assigns_and_persists_id_for_forked_response_item | RolloutItem::WorldState(_) | RolloutItem::EventMsg(_) => None, }); - assert_eq!(persisted_item_id, Some(live_item_id.as_str())); + assert_eq!( + persisted_item_id.map(ResponseItemId::as_str), + Some(live_item_id.as_str()) + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index bafb368b32d2..89f6e60948c1 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -80,6 +80,7 @@ use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputEnvironment; use codex_features::Feature; use codex_git_utils::get_git_repo_root_with_fs; +use codex_protocol::ResponseItemId; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ServiceTier; @@ -1919,13 +1920,11 @@ fn assign_missing_streamed_response_item_id( item: &mut ResponseItem, active_item: Option<&TurnItem>, ) { - if item.id().is_some() { + if item.id().is_some_and(|id| !id.is_empty()) { return; } - let active_item_id = active_item - .map(TurnItem::id) - .filter(|item_id| !item_id.is_empty()); + let active_item_id = active_item.map(|item| ResponseItemId::from_server(item.id())); item.set_id(active_item_id); Session::assign_missing_response_item_id(item); } diff --git a/codex-rs/core/src/session/turn_tests.rs b/codex-rs/core/src/session/turn_tests.rs index 051799cf850a..7ef829f13d4d 100644 --- a/codex-rs/core/src/session/turn_tests.rs +++ b/codex-rs/core/src/session/turn_tests.rs @@ -1,6 +1,7 @@ use super::*; use codex_extension_api::ExtensionData; use codex_extension_api::TurnItemContributor; +use codex_protocol::ResponseItemId; use codex_protocol::items::AgentMessageContent; use pretty_assertions::assert_eq; use std::sync::Arc; @@ -27,7 +28,7 @@ impl TurnItemContributor for RewriteAgentMessageContributor { fn assistant_output_text(text: &str) -> ResponseItem { ResponseItem::Message { - id: Some("msg-1".to_string()), + id: Some(ResponseItemId::with_suffix("msg", "1")), role: "assistant".to_string(), content: vec![ContentItem::OutputText { text: text.to_string(), diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 7d03a0d27155..55d72dc98a1b 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -2,6 +2,7 @@ use std::pin::Pin; use std::sync::Arc; use codex_extension_api::ExtensionData; +use codex_protocol::ResponseItemId; use codex_protocol::config_types::ModeKind; use codex_protocol::items::TurnItem; use codex_utils_stream_parser::strip_citations; @@ -444,7 +445,11 @@ pub(crate) async fn handle_non_tool_response_item( ResponseItem::ContextCompaction { .. } => "context_compaction", ResponseItem::Other => "other", }; - debug!(item_type, item_id = item.id(), "Output item"); + debug!( + item_type, + item_id = item.id().map(ResponseItemId::as_str), + "Output item" + ); match item { ResponseItem::Message { .. } diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index 80966b11fbea..eb04e25942a8 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -13,6 +13,7 @@ use crate::tools::parallel::ToolCallRuntime; use crate::turn_diff_tracker::TurnDiffTracker; use codex_extension_api::ExtensionData; use codex_extension_api::TurnItemContributor; +use codex_protocol::ResponseItemId; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::TurnItem; use codex_protocol::memory_citation::MemoryCitation; @@ -33,7 +34,7 @@ fn assistant_output_text(text: &str) -> ResponseItem { fn assistant_output_text_with_phase(text: &str, phase: Option) -> ResponseItem { ResponseItem::Message { - id: Some("msg-1".to_string()), + id: Some(ResponseItemId::with_suffix("msg", "1")), role: "assistant".to_string(), content: vec![ContentItem::OutputText { text: text.to_string(), diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index ff07ac5a928f..358333e02087 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use codex_prompts::render_review_exit_interrupted; use codex_prompts::render_review_exit_success; +use codex_protocol::ResponseItemId; use codex_protocol::config_types::WebSearchMode; use codex_protocol::items::ExitedReviewModeItem; use codex_protocol::items::TurnItem; @@ -242,7 +243,7 @@ pub(crate) async fn exit_review_mode( .record_conversation_items( &ctx, &[ResponseItem::Message { - id: Some(uuid::Uuid::now_v7().to_string()), + id: Some(ResponseItemId::new("msg")), role: "user".to_string(), content: vec![ContentItem::InputText { text: user_message }], phase: None, @@ -261,7 +262,7 @@ pub(crate) async fn exit_review_mode( .record_response_item_and_emit_turn_item( ctx.as_ref(), ResponseItem::Message { - id: Some(uuid::Uuid::now_v7().to_string()), + id: Some(ResponseItemId::new("msg")), role: "assistant".to_string(), content: vec![ContentItem::OutputText { text: assistant_message, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 7d00e9a9ce08..134384f26111 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -10,6 +10,7 @@ use crate::tasks::InterruptedTurnHistoryMarker; use crate::tasks::interrupted_turn_history_marker; use codex_extension_api::empty_extension_registry; use codex_models_manager::manager::RefreshStrategy; +use codex_protocol::ResponseItemId; use codex_protocol::capabilities::CapabilityRootLocation; use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::models::ContentItem; @@ -169,7 +170,7 @@ fn truncates_before_requested_user_message() { user_msg("u2"), assistant_msg("a3"), ResponseItem::Reasoning { - id: Some("r1".to_string()), + id: Some(ResponseItemId::with_suffix("rs", "1")), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "s".to_string(), }], diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index 5b6c8659651d..1ad3ca5d22bd 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::session::tests::build_world_state_from_turn_context; use crate::session::tests::make_session_and_context; use codex_protocol::AgentPath; +use codex_protocol::ResponseItemId; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::protocol::InterAgentCommunication; @@ -178,7 +179,7 @@ fn truncates_rollout_from_start_before_nth_user_only() { user_msg("u2"), assistant_msg("a3"), ResponseItem::Reasoning { - id: Some("r1".to_string()), + id: Some(ResponseItemId::with_suffix("rs", "1")), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "s".to_string(), }], diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 937fcc42b39a..328ad56d1123 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -110,7 +110,7 @@ async fn websocket_test_codex_shell_chain() -> Result<()> { ], vec![ ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), + ev_assistant_message("msg_1", "done"), ev_completed("resp-2"), ], ]]) @@ -155,7 +155,7 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create() -> Result<()> { vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hello"), + ev_assistant_message("msg_1", "hello"), ev_completed("resp-1"), ], ]]) @@ -213,7 +213,7 @@ async fn websocket_first_turn_handles_handshake_delay_with_startup_prewarm() -> vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hello"), + ev_assistant_message("msg_1", "hello"), ev_completed("resp-1"), ], ], @@ -268,7 +268,7 @@ async fn websocket_v2_test_codex_shell_chain() -> Result<()> { ], vec![ ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), + ev_assistant_message("msg_1", "done"), ev_completed("resp-2"), ], ]]) @@ -347,7 +347,7 @@ async fn websocket_v2_first_turn_uses_updated_fast_tier_after_startup_prewarm() vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "fast"), + ev_assistant_message("msg_1", "fast"), ev_completed("resp-1"), ], ]]) @@ -402,7 +402,7 @@ async fn websocket_v2_first_turn_drops_fast_tier_after_startup_prewarm() -> Resu vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "standard"), + ev_assistant_message("msg_1", "standard"), ev_completed("resp-1"), ], ]]) @@ -458,12 +458,12 @@ async fn websocket_v2_next_turn_uses_updated_service_tier() -> Result<()> { vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "fast"), + ev_assistant_message("msg_1", "fast"), ev_completed("resp-1"), ], vec![ ev_response_created("resp-2"), - ev_assistant_message("msg-2", "standard"), + ev_assistant_message("msg_2", "standard"), ev_completed("resp-2"), ], ]]) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 28802a76034b..9059a54850bd 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -20,6 +20,7 @@ use codex_model_provider_info::built_in_model_providers; use codex_models_manager::bundled_models_response; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; +use codex_protocol::ResponseItemId; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -388,7 +389,7 @@ async fn synthetic_call_output_id_is_stable_across_resumes() -> anyhow::Result<( RolloutLine { timestamp: "2024-01-01T00:00:01.000Z".to_string(), item: RolloutItem::ResponseItem(ResponseItem::FunctionCall { - id: Some("fc_existing".to_string()), + id: Some(ResponseItemId::with_suffix("fc", "existing")), name: "do_it".to_string(), namespace: None, arguments: "{}".to_string(), @@ -2992,7 +2993,7 @@ async fn includes_developer_instructions_message_in_request() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn azure_responses_request_includes_store_and_reasoning_ids() { +async fn azure_responses_request_includes_store_and_prefixed_item_ids() { skip_if_no_network!(); let server = MockServer::start().await; @@ -3071,7 +3072,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let mut prompt = Prompt::default(); prompt.input.push(ResponseItem::Reasoning { - id: Some("reasoning-id".into()), + id: Some(ResponseItemId::with_suffix("rs", "reasoning-id")), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".into(), }], @@ -3082,7 +3083,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::Message { - id: Some("message-id".into()), + id: Some(ResponseItemId::with_suffix("msg", "message-id")), role: "assistant".into(), content: vec![ContentItem::OutputText { text: "message".into(), @@ -3091,7 +3092,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::WebSearchCall { - id: Some("web-search-id".into()), + id: Some(ResponseItemId::with_suffix("ws", "web-search-id")), status: Some("completed".into()), action: Some(WebSearchAction::Search { query: Some("weather".into()), @@ -3100,7 +3101,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::FunctionCall { - id: Some("function-id".into()), + id: Some(ResponseItemId::with_suffix("fc", "function-id")), name: "do_thing".into(), namespace: None, arguments: "{}".into(), @@ -3114,7 +3115,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::LocalShellCall { - id: Some("local-shell-id".into()), + id: Some(ResponseItemId::with_suffix("lsh", "local-shell-id")), call_id: Some("local-shell-call-id".into()), status: LocalShellStatus::Completed, action: LocalShellAction::Exec(LocalShellExecAction { @@ -3127,7 +3128,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::CustomToolCall { - id: Some("custom-tool-id".into()), + id: Some(ResponseItemId::with_suffix("ctc", "custom-tool-id")), status: Some("completed".into()), call_id: "custom-tool-call-id".into(), name: "custom_tool".into(), @@ -3142,6 +3143,24 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { output: FunctionCallOutputPayload::from_text("ok".into()), internal_chat_message_metadata_passthrough: None, }); + prompt.input.push( + serde_json::from_value(json!({ + "type": "message", + "id": "018f9e15-7a6a-7000-8000-000000000001", + "role": "user", + "content": [{"type": "input_text", "text": "legacy message"}], + })) + .expect("legacy response item should deserialize"), + ); + prompt.input.push( + serde_json::from_value(json!({ + "type": "message", + "id": "", + "role": "user", + "content": [{"type": "input_text", "text": "empty-id message"}], + })) + .expect("response item with an empty id should deserialize"), + ); let mut stream = client_session .stream( @@ -3169,21 +3188,23 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { assert_eq!(body["store"], serde_json::Value::Bool(true)); assert_eq!(body["stream"], serde_json::Value::Bool(true)); - assert_eq!(body["input"].as_array().map(Vec::len), Some(8)); - assert_eq!(body["input"][0]["id"].as_str(), Some("reasoning-id")); - assert_eq!(body["input"][1]["id"].as_str(), Some("message-id")); - assert_eq!(body["input"][2]["id"].as_str(), Some("web-search-id")); - assert_eq!(body["input"][3]["id"].as_str(), Some("function-id")); + assert_eq!(body["input"].as_array().map(Vec::len), Some(10)); + assert_eq!(body["input"][0]["id"].as_str(), Some("rs_reasoning-id")); + assert_eq!(body["input"][1]["id"].as_str(), Some("msg_message-id")); + assert_eq!(body["input"][2]["id"].as_str(), Some("ws_web-search-id")); + assert_eq!(body["input"][3]["id"].as_str(), Some("fc_function-id")); assert_eq!( body["input"][4]["call_id"].as_str(), Some("function-call-id") ); - assert_eq!(body["input"][5]["id"].as_str(), Some("local-shell-id")); - assert_eq!(body["input"][6]["id"].as_str(), Some("custom-tool-id")); + assert_eq!(body["input"][5]["id"].as_str(), Some("lsh_local-shell-id")); + assert_eq!(body["input"][6]["id"].as_str(), Some("ctc_custom-tool-id")); assert_eq!( body["input"][7]["call_id"].as_str(), Some("custom-tool-call-id") ); + assert_eq!(body["input"][8].get("id"), None); + assert_eq!(body["input"][9].get("id"), None); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 114c24d2c1fc..9bf39425abd9 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -18,6 +18,7 @@ use codex_otel::MetricsConfig; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_otel::current_span_w3c_trace_context; +use codex_protocol::ResponseItemId; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; @@ -161,7 +162,7 @@ async fn responses_websocket_streams_request() { let harness = websocket_harness(&server).await; let mut client_session = harness.client.new_session(); let mut prompt = prompt_with_input(vec![message_item("hello")]); - prompt.input[0].set_id(Some("msg_existing".to_string())); + prompt.input[0].set_id(Some(ResponseItemId::with_suffix("msg", "existing"))); stream_until_complete(&mut client_session, &harness, &prompt).await; @@ -210,6 +211,59 @@ async fn responses_websocket_streams_request() { server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_omits_unprefixed_item_ids_without_mutating_prompt() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![vec![ + ev_response_created("resp-1"), + ev_completed("resp-1"), + ]]]) + .await; + + let harness = websocket_harness_with_provider_options( + websocket_provider(&server), + /*runtime_metrics_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, + /*enabled_features*/ &[Feature::ItemIds], + ) + .await; + let mut client_session = harness.client.new_session(); + let mut prefixed = message_item("prefixed"); + prefixed.set_id(Some(ResponseItemId::with_suffix("msg", "existing"))); + let unprefixed = serde_json::from_value(json!({ + "type": "message", + "id": "018f9e15-7a6a-7000-8000-000000000001", + "role": "user", + "content": [{"type": "input_text", "text": "legacy message"}], + })) + .expect("legacy response item should deserialize"); + let empty = serde_json::from_value(json!({ + "type": "message", + "id": "", + "role": "user", + "content": [{"type": "input_text", "text": "empty-id message"}], + })) + .expect("response item with an empty id should deserialize"); + let prompt = prompt_with_input(vec![prefixed, unprefixed, empty]); + + stream_until_complete(&mut client_session, &harness, &prompt).await; + + let connection = server.single_connection(); + let body = connection.first().expect("missing request").body_json(); + assert_eq!(body["input"].as_array().map(Vec::len), Some(3)); + assert_eq!(body["input"][0]["id"].as_str(), Some("msg_existing")); + assert_eq!(body["input"][1].get("id"), None); + assert_eq!(body["input"][2].get("id"), None); + assert_eq!( + serde_json::to_value(&prompt.input).expect("prompt input should serialize")[1]["id"] + .as_str(), + Some("018f9e15-7a6a-7000-8000-000000000001") + ); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_streams_without_feature_flag_when_provider_supports_websockets() { skip_if_no_network!(); @@ -969,7 +1023,7 @@ async fn responses_websocket_v2_requests_use_v2_when_provider_supports_websocket let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "assistant output"), + ev_assistant_message("msg_1", "assistant output"), ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], @@ -981,7 +1035,7 @@ async fn responses_websocket_v2_requests_use_v2_when_provider_supports_websocket let prompt_one = prompt_with_input(vec![message_item("hello")]); let prompt_two = prompt_with_input(vec![ message_item("hello"), - assistant_message_item("msg-1", "assistant output"), + assistant_message_item("1", "assistant output"), message_item("second"), ]); @@ -1015,11 +1069,11 @@ async fn responses_websocket_v2_requests_use_v2_when_provider_supports_websocket async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() { skip_if_no_network!(); - let mut assistant_output_with_turn_id = ev_assistant_message("msg-1", "assistant output"); + let mut assistant_output_with_turn_id = ev_assistant_message("msg_1", "assistant output"); assistant_output_with_turn_id["item"]["internal_chat_message_metadata_passthrough"] = json!({ "turn_id": "turn-1" }); - let assistant_output_without_turn_id = ev_assistant_message("msg-2", "second assistant output"); + let assistant_output_without_turn_id = ev_assistant_message("msg_2", "second assistant output"); let server = start_websocket_server(vec![vec![ vec![ @@ -1055,7 +1109,7 @@ async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() { } // Turn two: the response and reconstructed history have matching metadata. - let mut first_assistant_output = assistant_message_item("msg-1", "assistant output"); + let mut first_assistant_output = assistant_message_item("1", "assistant output"); first_assistant_output.set_turn_id_if_missing("turn-1"); let prompt_two = prompt_with_input(vec![ @@ -1070,7 +1124,7 @@ async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() { } // Turn three: the reconstructed history has metadata that the response omitted. - let mut second_assistant_output = assistant_message_item("msg-2", "second assistant output"); + let mut second_assistant_output = assistant_message_item("2", "second assistant output"); second_assistant_output.set_turn_id_if_missing("turn-2"); let prompt_three = prompt_with_input(vec![ @@ -1118,7 +1172,7 @@ async fn responses_websocket_v2_wins_when_both_features_enabled() { let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "assistant output"), + ev_assistant_message("msg_1", "assistant output"), ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], @@ -1130,7 +1184,7 @@ async fn responses_websocket_v2_wins_when_both_features_enabled() { let prompt_one = prompt_with_input(vec![message_item("hello")]); let prompt_two = prompt_with_input(vec![ message_item("hello"), - assistant_message_item("msg-1", "assistant output"), + assistant_message_item("1", "assistant output"), message_item("second"), ]); @@ -1628,7 +1682,7 @@ async fn responses_websocket_uses_incremental_create_on_prefix() { let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "assistant output"), + ev_assistant_message("msg_1", "assistant output"), ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], @@ -1640,7 +1694,7 @@ async fn responses_websocket_uses_incremental_create_on_prefix() { let prompt_one = prompt_with_input(vec![message_item("hello")]); let prompt_two = prompt_with_input(vec![ message_item("hello"), - assistant_message_item("msg-1", "assistant output"), + assistant_message_item("1", "assistant output"), message_item("second"), ]); @@ -1670,7 +1724,7 @@ async fn responses_websocket_uses_incremental_create_on_prefix() { async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_create() { skip_if_no_network!(); - let mut first_output_item = ev_assistant_message("msg-1", "assistant output"); + let mut first_output_item = ev_assistant_message("msg_1", "assistant output"); first_output_item["item"]["internal_chat_message_metadata_passthrough"] = json!({"turn_id": "turn-123"}); let server = start_websocket_server(vec![vec![ @@ -1686,7 +1740,7 @@ async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_c let harness = websocket_harness(&server).await; let mut client_session = harness.client.new_session(); let prompt_one = prompt_with_input(vec![message_item("hello")]); - let mut prior_assistant_output = assistant_message_item("msg-1", "assistant output"); + let mut prior_assistant_output = assistant_message_item("1", "assistant output"); prior_assistant_output.set_turn_id_if_missing("turn-123"); let prompt_two = prompt_with_input(vec![ message_item("hello"), @@ -1801,7 +1855,7 @@ async fn responses_websocket_uses_previous_response_id_when_prefix_after_complet let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "assistant output"), + ev_assistant_message("msg_1", "assistant output"), ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], @@ -1813,7 +1867,7 @@ async fn responses_websocket_uses_previous_response_id_when_prefix_after_complet let prompt_one = prompt_with_input(vec![message_item("hello")]); let prompt_two = prompt_with_input(vec![ message_item("hello"), - assistant_message_item("msg-1", "assistant output"), + assistant_message_item("1", "assistant output"), message_item("second"), ]); @@ -1910,7 +1964,7 @@ async fn responses_websocket_v2_creates_with_previous_response_id_on_prefix() { let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "assistant output"), + ev_assistant_message("msg_1", "assistant output"), ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], @@ -1922,7 +1976,7 @@ async fn responses_websocket_v2_creates_with_previous_response_id_on_prefix() { let prompt_one = prompt_with_input(vec![message_item("hello")]); let prompt_two = prompt_with_input(vec![ message_item("hello"), - assistant_message_item("msg-1", "assistant output"), + assistant_message_item("1", "assistant output"), message_item("second"), ]); @@ -2176,7 +2230,7 @@ fn message_item(text: &str) -> ResponseItem { fn assistant_message_item(id: &str, text: &str) -> ResponseItem { ResponseItem::Message { - id: Some(id.to_string()), + id: Some(ResponseItemId::with_suffix("msg", id)), role: "assistant".into(), content: vec![ContentItem::OutputText { text: text.into() }], phase: None, diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index 1ec9371214a4..6a87cd940aca 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -7,6 +7,7 @@ use codex_core::context::extension_image_generation_output_hint; use codex_extension_api::ToolOutput; use codex_extension_api::ToolPayload; use codex_extension_api::ToolSpec; +use codex_protocol::ResponseItemId; use codex_protocol::models::ContentItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputBody; @@ -109,7 +110,7 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { internal_chat_message_metadata_passthrough: None, }, ResponseItem::ImageGenerationCall { - id: Some("generated-call".to_string()), + id: Some(ResponseItemId::with_suffix("ig", "generated-call")), status: "completed".to_string(), revised_prompt: None, result: "generated".to_string(), diff --git a/codex-rs/ext/web-search/src/history.rs b/codex-rs/ext/web-search/src/history.rs index 10fb0dd65e9b..bd1e8182ff93 100644 --- a/codex-rs/ext/web-search/src/history.rs +++ b/codex-rs/ext/web-search/src/history.rs @@ -82,6 +82,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { #[cfg(test)] mod tests { use codex_api::SearchInput; + use codex_protocol::ResponseItemId; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; @@ -111,9 +112,12 @@ mod tests { #[test] fn keeps_current_user_and_previous_visible_turn() { let mut previous_user = message(USER_ROLE, "previous user"); - previous_user.set_id(Some("msg_previous_user".to_string())); + previous_user.set_id(Some(ResponseItemId::with_suffix("msg", "previous_user"))); let mut previous_assistant = message(ASSISTANT_ROLE, "previous assistant"); - previous_assistant.set_id(Some("msg_previous_assistant".to_string())); + previous_assistant.set_id(Some(ResponseItemId::with_suffix( + "msg", + "previous_assistant", + ))); let items = vec![ message("system", "system"), message(USER_ROLE, "old user"), diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index a65bd970dfcb..f3b261f06970 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -1,4 +1,5 @@ use crate::AgentPath; +use crate::ResponseItemId; use crate::ThreadId; use crate::dynamic_tools::DynamicToolCallOutputContentItem; use crate::mcp::CallToolResult; @@ -528,9 +529,9 @@ fn trim_trailing_default_image_details( } impl HookPromptItem { - pub fn from_fragments(id: Option<&String>, fragments: Vec) -> Self { + pub fn from_fragments(id: Option<&str>, fragments: Vec) -> Self { Self { - id: id.cloned().unwrap_or_else(new_item_id), + id: id.map(str::to_string).unwrap_or_else(new_item_id), fragments, } } @@ -560,7 +561,7 @@ pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option Option, + id: Option<&str>, content: &[ContentItem], ) -> Option { let fragments = content @@ -660,9 +661,10 @@ mod tests { ]; let message = build_hook_prompt_message(&original).expect("hook prompt"); - let ResponseItem::Message { content, .. } = message else { + let ResponseItem::Message { id, content, .. } = message else { panic!("expected hook prompt message"); }; + assert!(id.is_some_and(|id| id.starts_with("msg_"))); let parsed = parse_hook_prompt_message(/*id*/ None, &content).expect("parsed hook prompt"); assert_eq!(parsed.fragments, original); diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 5115ebcaae37..b2b04b17a8b8 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -1,10 +1,12 @@ pub mod account; mod agent_path; pub mod auth; +mod response_item_id; mod session_id; mod thread_id; mod tool_name; pub use agent_path::AgentPath; +pub use response_item_id::ResponseItemId; pub use session_id::SessionId; pub use thread_id::ThreadId; pub use tool_name::ToolName; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 46aecb61b97f..91fd42a5558a 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -26,6 +26,7 @@ use codex_utils_image::ImageProcessingError; use codex_utils_path_uri::PathUri; use schemars::JsonSchema; +use crate::ResponseItemId; use crate::mcp::CallToolResult; /// Controls the per-command sandbox override requested by a shell-like tool call. @@ -936,14 +937,14 @@ pub enum ResponseItem { #[ts(skip)] AdditionalTools { #[serde(default, skip_serializing_if = "Option::is_none")] - id: Option, + id: Option, role: String, tools: Vec, }, Message { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, role: String, content: Vec, // Optional output-message phase (for example: "commentary", "final_answer"). @@ -959,7 +960,7 @@ pub enum ResponseItem { AgentMessage { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, author: String, recipient: String, content: Vec, @@ -970,7 +971,7 @@ pub enum ResponseItem { Reasoning { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, summary: Vec, #[serde(default, skip_serializing_if = "should_serialize_reasoning_content")] #[ts(optional)] @@ -984,7 +985,7 @@ pub enum ResponseItem { /// Legacy id field retained for compatibility with older payloads. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, /// Set when using the Responses API. call_id: Option, status: LocalShellStatus, @@ -996,7 +997,7 @@ pub enum ResponseItem { FunctionCall { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, name: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1013,7 +1014,7 @@ pub enum ResponseItem { ToolSearchCall { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, call_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1033,7 +1034,7 @@ pub enum ResponseItem { FunctionCallOutput { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, call_id: String, #[ts(as = "FunctionCallOutputBody")] #[schemars(with = "FunctionCallOutputBody")] @@ -1045,7 +1046,7 @@ pub enum ResponseItem { CustomToolCall { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] status: Option, @@ -1066,7 +1067,7 @@ pub enum ResponseItem { CustomToolCallOutput { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1081,7 +1082,7 @@ pub enum ResponseItem { ToolSearchOutput { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, call_id: Option, status: String, execution: String, @@ -1102,7 +1103,7 @@ pub enum ResponseItem { WebSearchCall { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] status: Option, @@ -1125,7 +1126,7 @@ pub enum ResponseItem { ImageGenerationCall { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, status: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1139,7 +1140,7 @@ pub enum ResponseItem { Compaction { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, encrypted_content: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1150,7 +1151,7 @@ pub enum ResponseItem { ContextCompaction { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - id: Option, + id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] encrypted_content: Option, @@ -1168,8 +1169,8 @@ impl ResponseItem { matches!(self, Self::Message { role, .. } if role == "user") } - /// Returns the non-empty Responses API item ID, if present. - pub fn id(&self) -> Option<&str> { + /// Returns the Responses API item ID, if present. + pub fn id(&self) -> Option<&ResponseItemId> { match self { Self::AdditionalTools { id, .. } | Self::Message { id, .. } @@ -1185,13 +1186,13 @@ impl ResponseItem { | Self::Reasoning { id, .. } | Self::ImageGenerationCall { id, .. } | Self::Compaction { id, .. } - | Self::ContextCompaction { id, .. } => id.as_deref().filter(|id| !id.is_empty()), + | Self::ContextCompaction { id, .. } => id.as_ref(), Self::CompactionTrigger { .. } | Self::Other => None, } } /// Sets or clears the Responses API item ID for variants that carry one. - pub fn set_id(&mut self, new_id: Option) { + pub fn set_id(&mut self, new_id: Option) { match self { Self::AdditionalTools { id, .. } | Self::Message { id, .. } @@ -1212,6 +1213,27 @@ impl ResponseItem { } } + /// Returns the Responses API item ID prefix for variants that carry an ID. + pub fn id_prefix(&self) -> Option<&'static str> { + match self { + Self::AdditionalTools { .. } => Some("at"), + Self::Message { .. } => Some("msg"), + Self::AgentMessage { .. } => Some("amsg"), + Self::Reasoning { .. } => Some("rs"), + Self::LocalShellCall { .. } => Some("lsh"), + Self::FunctionCall { .. } => Some("fc"), + Self::ToolSearchCall { .. } => Some("tsc"), + Self::FunctionCallOutput { .. } => Some("fco"), + Self::CustomToolCall { .. } => Some("ctc"), + Self::CustomToolCallOutput { .. } => Some("ctco"), + Self::ToolSearchOutput { .. } => Some("tso"), + Self::WebSearchCall { .. } => Some("ws"), + Self::ImageGenerationCall { .. } => Some("ig"), + Self::Compaction { .. } | Self::ContextCompaction { .. } => Some("cmp"), + Self::CompactionTrigger { .. } | Self::Other => None, + } + } + /// Returns the non-empty turn ID stamped onto this item, if present. pub fn turn_id(&self) -> Option<&str> { self.internal_chat_message_metadata_passthrough() @@ -2243,9 +2265,9 @@ mod tests { ); assert_eq!(item.id(), None); - item.set_id(Some("msg_test".to_string())); + item.set_id(Some(ResponseItemId::with_suffix("msg", "test"))); - assert_eq!(item.id(), Some("msg_test")); + assert_eq!(item.id().map(ResponseItemId::as_str), Some("msg_test")); item.set_id(/*new_id*/ None); @@ -2256,8 +2278,11 @@ mod tests { role: "developer".to_string(), tools: Vec::new(), }; - additional_tools.set_id(Some("at_test".to_string())); - assert_eq!(additional_tools.id(), Some("at_test")); + additional_tools.set_id(Some(ResponseItemId::with_suffix("at", "test"))); + assert_eq!( + additional_tools.id().map(ResponseItemId::as_str), + Some("at_test") + ); } fn response_item_with_passthrough_metadata( @@ -2377,7 +2402,7 @@ mod tests { assert_eq!( item, ResponseItem::ImageGenerationCall { - id: Some("ig_123".to_string()), + id: Some(ResponseItemId::with_suffix("ig", "123")), status: "completed".to_string(), revised_prompt: Some("A small blue square".to_string()), result: "Zm9v".to_string(), @@ -2399,7 +2424,7 @@ mod tests { assert_eq!( item, ResponseItem::ImageGenerationCall { - id: Some("ig_123".to_string()), + id: Some(ResponseItemId::with_suffix("ig", "123")), status: "completed".to_string(), revised_prompt: None, result: "Zm9v".to_string(), @@ -3225,7 +3250,7 @@ mod tests { "status": "in_progress", "id": "ws_partial" }"#, - Some("ws_partial".into()), + Some(ResponseItemId::with_suffix("ws", "partial")), None, Some("in_progress".into()), ), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 94d066bd38ee..f0ec2bf85092 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -16,6 +16,7 @@ use std::time::Duration; use strum_macros::EnumIter; use crate::AgentPath; +use crate::ResponseItemId; use crate::SessionId; use crate::ThreadId; use crate::approvals::ElicitationRequestEvent; @@ -736,7 +737,7 @@ impl From> for Op { pub struct InterAgentCommunication { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - pub id: Option, + pub id: Option, pub author: AgentPath, pub recipient: AgentPath, #[serde(default)] @@ -4509,7 +4510,7 @@ mod tests { #[test] fn inter_agent_communication_response_input_item_preserves_commentary_phase() { let mut communication = InterAgentCommunication { - id: Some("amsg_1".to_string()), + id: Some(ResponseItemId::with_suffix("amsg", "1")), author: AgentPath::root(), recipient: AgentPath::root().join("reviewer").expect("recipient path"), other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], diff --git a/codex-rs/protocol/src/response_item_id.rs b/codex-rs/protocol/src/response_item_id.rs new file mode 100644 index 000000000000..9aa66b78da89 --- /dev/null +++ b/codex-rs/protocol/src/response_item_id.rs @@ -0,0 +1,70 @@ +use std::fmt; +use std::ops::Deref; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +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, +)] +#[serde(transparent)] +#[schemars(with = "String")] +#[ts(type = "string")] +pub struct ResponseItemId(String); + +impl ResponseItemId { + pub fn new(prefix: &str) -> Self { + Self::with_suffix(prefix, uuid::Uuid::now_v7()) + } + + pub fn with_suffix(prefix: &str, suffix: impl fmt::Display) -> Self { + Self(format!("{prefix}_{suffix}")) + } + + pub fn from_server(value: String) -> Self { + Self(value) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn is_prefixed(&self) -> bool { + self.split_once('_') + .is_some_and(|(prefix, suffix)| !prefix.is_empty() && !suffix.is_empty()) + } +} + +impl Deref for ResponseItemId { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +impl AsRef for ResponseItemId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for ResponseItemId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl From for String { + fn from(value: ResponseItemId) -> Self { + value.0 + } +} + +#[cfg(test)] +#[path = "response_item_id_tests.rs"] +mod tests; diff --git a/codex-rs/protocol/src/response_item_id_tests.rs b/codex-rs/protocol/src/response_item_id_tests.rs new file mode 100644 index 000000000000..40c834957867 --- /dev/null +++ b/codex-rs/protocol/src/response_item_id_tests.rs @@ -0,0 +1,53 @@ +use pretty_assertions::assert_eq; +use uuid::Uuid; + +use super::ResponseItemId; + +#[test] +fn creates_prefixed_uuid_v7_ids() { + let id = ResponseItemId::new("msg"); + let uuid = id.strip_prefix("msg_").expect("message prefix"); + assert_eq!( + Uuid::parse_str(uuid).expect("UUID suffix").get_version(), + Some(uuid::Version::SortRand) + ); +} + +#[test] +fn creates_prefixed_ids_with_explicit_suffix() { + let id = ResponseItemId::with_suffix("msg", "test"); + assert_eq!(id.as_str(), "msg_test"); + assert_eq!(id.to_string(), "msg_test"); + assert_eq!(String::from(id), "msg_test"); +} + +#[test] +fn accepts_server_ids_verbatim() { + let id = ResponseItemId::from_server("legacy-id".to_string()); + assert_eq!(id.as_str(), "legacy-id"); +} + +#[test] +fn deserializes_arbitrary_ids_as_strings() { + let id: ResponseItemId = serde_json::from_str("\"legacy-id\"").expect("id"); + assert_eq!(id.as_str(), "legacy-id"); + assert_eq!( + serde_json::to_string(&id).expect("serialized id"), + "\"legacy-id\"" + ); +} + +#[test] +fn recognizes_non_empty_prefix_and_suffix() { + for (value, expected) in [ + ("msg_test", true), + ("legacy-id", false), + ("", false), + ("_test", false), + ("msg_", false), + ] { + let id: ResponseItemId = + serde_json::from_value(value.into()).expect("ID should deserialize"); + assert_eq!(id.is_prefixed(), expected, "{value}"); + } +} diff --git a/codex-rs/rollout-trace/src/inference.rs b/codex-rs/rollout-trace/src/inference.rs index 66f26f9758a9..7cea4d0f9752 100644 --- a/codex-rs/rollout-trace/src/inference.rs +++ b/codex-rs/rollout-trace/src/inference.rs @@ -391,6 +391,7 @@ fn append_with_context_best_effort( mod tests { use std::sync::Arc; + use codex_protocol::ResponseItemId; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use pretty_assertions::assert_eq; @@ -496,7 +497,7 @@ mod tests { #[test] fn traced_response_item_preserves_reasoning_content_omitted_by_normal_serializer() { let item = ResponseItem::Reasoning { - id: Some("rs-1".to_string()), + id: Some(ResponseItemId::with_suffix("rs", "1")), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".to_string(), }], @@ -515,7 +516,7 @@ mod tests { traced, json!({ "type": "reasoning", - "id": "rs-1", + "id": "rs_1", "summary": [{"type": "summary_text", "text": "summary"}], "content": [{"type": "text", "text": "raw reasoning"}], "encrypted_content": "encoded",