diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a5d75e644b..1b94a20691 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1998,6 +1998,13 @@ "type": "null" } ] + }, + "uuid": { + "description": "Client-visible UUID generated by Codex for this item.", + "type": [ + "string", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index bc0a130784..605a1ad1a6 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -10589,6 +10589,13 @@ "type": "null" } ] + }, + "uuid": { + "description": "Client-visible UUID generated by Codex for this item.", + "type": [ + "string", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index af7a37256c..d3bbe34fbd 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7377,6 +7377,13 @@ "type": "null" } ] + }, + "uuid": { + "description": "Client-visible UUID generated by Codex for this item.", + "type": [ + "string", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index e469d38621..c32f4a35ab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -836,6 +836,13 @@ "type": "null" } ] + }, + "uuid": { + "description": "Client-visible UUID generated by Codex for this item.", + "type": [ + "string", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 9130061317..db9ac3a8b5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -902,6 +902,13 @@ "type": "null" } ] + }, + "uuid": { + "description": "Client-visible UUID generated by Codex for this item.", + "type": [ + "string", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts index e7ce27e9af..073dbf14d1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { UserMessageType } from "./UserMessageType"; -export type ResponseItemMetadata = { user_message_type?: UserMessageType, }; +export type ResponseItemMetadata = { user_message_type?: UserMessageType, +/** + * Client-visible UUID generated by Codex for this item. + */ +uuid?: string, }; diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 04bef66557..0714ea429e 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2723,8 +2723,11 @@ mod tests { use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::TurnPlanStepStatus; use codex_protocol::mcp::CallToolResult; + use codex_protocol::models::ContentItem; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; + use codex_protocol::models::ResponseItem; + use codex_protocol::models::ResponseItemMetadata; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::protocol::CollabResumeBeginEvent; @@ -3756,4 +3759,74 @@ mod tests { assert!(rx.try_recv().is_err(), "no messages expected"); Ok(()) } + + #[tokio::test] + async fn raw_response_item_completed_preserves_existing_metadata_uuid() -> Result<()> { + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let conversation_id = ThreadId::new(); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + conversation_id.clone(), + ); + let expected_uuid = uuid::Uuid::new_v4().to_string(); + + maybe_emit_raw_response_item_completed( + ApiVersion::V2, + conversation_id.clone(), + "turn-1", + ResponseItem::Message { + id: Some("msg_123".to_string()), + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "hello".to_string(), + }], + metadata: Some(ResponseItemMetadata { + user_message_type: None, + uuid: Some(expected_uuid.clone()), + }), + end_turn: None, + phase: None, + }, + &outgoing, + ) + .await; + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification( + ServerNotification::RawResponseItemCompleted(notification), + ) => { + assert_eq!(notification.thread_id, conversation_id.to_string()); + assert_eq!(notification.turn_id, "turn-1"); + match notification.item { + ResponseItem::Message { + id, + metadata, + role, + content, + .. + } => { + assert_eq!(id, Some("msg_123".to_string())); + assert_eq!(role, "assistant"); + assert_eq!( + content, + vec![ContentItem::OutputText { + text: "hello".to_string() + }] + ); + let metadata = metadata.expect("metadata should be present"); + assert_eq!(metadata.user_message_type, None); + let uuid = metadata.uuid.expect("uuid should be present"); + assert_eq!(uuid, expected_uuid); + } + other => bail!("unexpected response item: {other:?}"), + } + } + other => bail!("unexpected message: {other:?}"), + } + assert!(rx.try_recv().is_err(), "no extra messages expected"); + Ok(()) + } } diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index e88e1af124..f20f876781 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -46,7 +46,12 @@ pub struct Prompt { impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { - let mut input = self.input.clone(); + let mut input: Vec = self + .input + .clone() + .into_iter() + .map(ResponseItem::with_generated_metadata_uuid) + .collect(); // when using the *Freeform* apply_patch tool specifically, tool outputs // should be structured text, not json. Do NOT reserialize when using diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 769defabbc..ac9c34f59a 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -3,7 +3,10 @@ use codex_api::common::OpenAiVerbosity; use codex_api::common::TextControls; use codex_api::create_text_param_for_request; use codex_protocol::config_types::ServiceTier; +use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseItemMetadata; +use codex_protocol::models::UserMessageType; use pretty_assertions::assert_eq; use super::*; @@ -196,6 +199,39 @@ fn reserializes_shell_outputs_for_function_and_custom_tool_calls() { ); } +#[test] +fn formatted_input_generates_message_metadata_uuid() { + let prompt = Prompt { + input: vec![ResponseItem::Message { + id: Some("msg_123".to_string()), + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "hello".to_string(), + }], + metadata: Some(ResponseItemMetadata { + user_message_type: Some(UserMessageType::Prompt), + uuid: None, + }), + end_turn: None, + phase: None, + }], + ..Default::default() + }; + + let formatted = prompt.get_formatted_input(); + assert_eq!(formatted.len(), 1); + + match &formatted[0] { + ResponseItem::Message { metadata, .. } => { + let metadata = metadata.as_ref().expect("metadata should be present"); + assert_eq!(metadata.user_message_type, Some(UserMessageType::Prompt)); + let uuid = metadata.uuid.as_deref().expect("uuid should be present"); + uuid::Uuid::parse_str(uuid).expect("uuid should be valid"); + } + other => panic!("expected message item, got {other:?}"), + } +} + #[test] fn tool_search_output_namespace_serializes_with_deferred_child_tools() { let namespace = tools::ToolSearchOutputTool::Namespace(tools::ResponsesApiNamespace { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 123cf073fc..5c152a66b0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3277,9 +3277,14 @@ impl Session { turn_context: &TurnContext, items: &[ResponseItem], ) { - self.record_into_history(items, turn_context).await; - self.persist_rollout_response_items(items).await; - self.send_raw_response_items(turn_context, items).await; + let items: Vec = items + .iter() + .cloned() + .map(ResponseItem::with_generated_metadata_uuid) + .collect(); + self.record_into_history(&items, turn_context).await; + self.persist_rollout_response_items(&items).await; + self.send_raw_response_items(turn_context, &items).await; } /// Append ResponseItems to the in-memory conversation history only. diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 38da6d541f..33d9104f3a 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -267,6 +267,47 @@ pub struct ResponseItemMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub user_message_type: Option, + /// Client-visible UUID generated by Codex for this item. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub uuid: Option, +} + +impl ResponseItem { + /// Ensures message metadata includes a generated UUID. + pub fn with_generated_metadata_uuid(self) -> Self { + match self { + ResponseItem::Message { + id, + role, + content, + metadata, + end_turn, + phase, + } => { + let metadata = ensure_generated_metadata_uuid(metadata); + ResponseItem::Message { + id, + role, + content, + metadata, + end_turn, + phase, + } + } + other => other, + } + } +} + +fn ensure_generated_metadata_uuid( + metadata: Option, +) -> Option { + let mut metadata = metadata.unwrap_or_default(); + if metadata.uuid.is_none() { + metadata.uuid = Some(uuid::Uuid::new_v4().to_string()); + } + Some(metadata) } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] @@ -2958,4 +2999,51 @@ mod tests { Ok(()) } + + #[test] + fn generates_metadata_uuid_for_message_items() -> Result<()> { + let item = ResponseItem::Message { + id: Some("msg_123".to_string()), + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "hello".to_string(), + }], + metadata: Some(ResponseItemMetadata { + user_message_type: Some(UserMessageType::Prompt), + uuid: None, + }), + end_turn: None, + phase: None, + }; + + let surfaced = item.with_generated_metadata_uuid(); + let serialized = serde_json::to_value(&surfaced)?; + let metadata = serialized + .get("metadata") + .and_then(serde_json::Value::as_object) + .expect("metadata should be present"); + let uuid = metadata + .get("uuid") + .and_then(serde_json::Value::as_str) + .expect("uuid should be present"); + + uuid::Uuid::parse_str(uuid).expect("uuid should be valid"); + + assert_eq!(serialized.get("type"), Some(&serde_json::json!("message"))); + assert_eq!( + serialized.get("role"), + Some(&serde_json::json!("assistant")) + ); + assert_eq!( + serialized.get("content"), + Some(&serde_json::json!([{ "type": "output_text", "text": "hello" }])) + ); + assert_eq!( + metadata.get("user_message_type"), + Some(&serde_json::json!("prompt")) + ); + assert_ne!(uuid, "msg_123"); + + Ok(()) + } }