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
This commit is contained in:
pakrym-oai
2026-07-11 02:29:14 +00:00
committed by copyberry
parent 08ba14b03d
commit c9d52de5ca
38 changed files with 430 additions and 160 deletions
@@ -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<ContentItem>, phase?: MessagePhase, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "agent_message", id?: string, author: string, recipient: string, content: Array<AgentMessageInputContent>, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "reasoning", id?: string, summary: Array<ReasoningItemReasoningSummary>, content?: Array<ReasoningItemContent>, 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<ContentItem>, phase?: MessagePhase, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "agent_message", id?: ResponseItemId, author: string, recipient: string, content: Array<AgentMessageInputContent>, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "reasoning", id?: ResponseItemId, summary: Array<ReasoningItemReasoningSummary>, content?: Array<ReasoningItemContent>, 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" };
@@ -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;
+1
View File
@@ -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";
@@ -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(),
@@ -892,6 +892,7 @@ fn serialize_websocket_request(request: &ResponsesWsRequest) -> Result<String, A
mod tests {
use super::*;
use crate::common::ResponseCreateWsRequest;
use codex_protocol::ResponseItemId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
@@ -905,7 +906,7 @@ mod tests {
instructions: "Use the available tools.".to_string(),
previous_response_id: Some("resp-1".to_string()),
input: vec![ResponseItem::Message {
id: Some("msg-1".to_string()),
id: Some(ResponseItemId::with_suffix("msg", "1")),
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "hello".to_string(),
+2 -1
View File
@@ -70,6 +70,7 @@ mod tests {
use codex_client::Response;
use codex_client::StreamResponse;
use codex_client::TransportError;
use codex_protocol::ResponseItemId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use http::StatusCode;
@@ -150,7 +151,7 @@ mod tests {
model: "gpt-test".to_string(),
reasoning: None,
input: Some(SearchInput::Items(vec![ResponseItem::Message {
id: Some("msg_search".to_string()),
id: Some(ResponseItemId::with_suffix("msg", "search")),
role: "user".to_string(),
content: vec![
ContentItem::InputText {
+3 -2
View File
@@ -19,6 +19,7 @@ use codex_client::RequestBody;
use codex_client::Response;
use codex_client::StreamResponse;
use codex_client::TransportError;
use codex_protocol::ResponseItemId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::SessionSource;
@@ -309,7 +310,7 @@ async fn responses_client_stream_request_preserves_item_ids() -> 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,
+2 -1
View File
@@ -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,
+6
View File
@@ -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;
}
+3 -3
View File
@@ -414,7 +414,7 @@ fn started_inference_attempt(temp: &TempDir) -> anyhow::Result<InferenceTraceAtt
fn output_message(id: &str, text: &str) -> 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 {
+2 -1
View File
@@ -434,13 +434,14 @@ fn rewritten_output_for_context_window(item: &ResponseItem) -> Option<ResponseIt
internal_chat_message_metadata_passthrough: metadata.clone(),
},
ResponseItem::ToolSearchOutput {
id,
call_id,
status,
execution,
internal_chat_message_metadata_passthrough: metadata,
..
} => ResponseItem::ToolSearchOutput {
id: item.id().map(str::to_string),
id: id.clone(),
call_id: call_id.clone(),
status: status.clone(),
execution: execution.clone(),
+3 -2
View File
@@ -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(),
@@ -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<HookPromptItem> {
let mut fragments = Vec::new();
@@ -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(),
@@ -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<ResponseItem>) {
/// 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<String> {
fn synthetic_output_id(prefix: &str, item_id: Option<&str>) -> Option<ResponseItemId> {
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()),
))
}
+9 -7
View File
@@ -121,7 +121,7 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
}
fn parse_agent_message(
id: Option<&String>,
id: Option<&str>,
message: &[ContentItem],
phase: Option<MessagePhase>,
) -> 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<TurnItem> {
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<TurnItem> {
})
.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<TurnItem> {
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<TurnItem> {
..
} => 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(),
+11 -10
View File
@@ -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,
+9 -19
View File
@@ -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<UserInput>) -> ResponseItem {
+16 -7
View File
@@ -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)]
+3 -4
View File
@@ -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);
}
+2 -1
View File
@@ -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(),
+6 -1
View File
@@ -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 { .. }
@@ -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<MessagePhase>) -> 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(),
+3 -2
View File
@@ -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,
+2 -1
View File
@@ -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(),
}],
@@ -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(),
}],
+8 -8
View File
@@ -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"),
],
]])
+36 -15
View File
@@ -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)]
+72 -18
View File
@@ -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,
+2 -1
View File
@@ -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(),
+6 -2
View File
@@ -82,6 +82,7 @@ fn push_visible_message(messages: &mut Vec<ResponseItem>, 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"),
+7 -5
View File
@@ -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<HookPromptFragment>) -> Self {
pub fn from_fragments(id: Option<&str>, fragments: Vec<HookPromptFragment>) -> 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<Res
}
Some(ResponseItem::Message {
id: Some(new_item_id()),
id: Some(ResponseItemId::new("msg")),
role: "user".to_string(),
content,
phase: None,
@@ -569,7 +570,7 @@ pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option<Res
}
pub fn parse_hook_prompt_message(
id: Option<&String>,
id: Option<&str>,
content: &[ContentItem],
) -> Option<HookPromptItem> {
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);
+2
View File
@@ -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;
+51 -26
View File
@@ -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<String>,
id: Option<ResponseItemId>,
role: String,
tools: Vec<serde_json::Value>,
},
Message {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
id: Option<ResponseItemId>,
role: String,
content: Vec<ContentItem>,
// 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<String>,
id: Option<ResponseItemId>,
author: String,
recipient: String,
content: Vec<AgentMessageInputContent>,
@@ -970,7 +971,7 @@ pub enum ResponseItem {
Reasoning {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
id: Option<ResponseItemId>,
summary: Vec<ReasoningItemReasoningSummary>,
#[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<String>,
id: Option<ResponseItemId>,
/// Set when using the Responses API.
call_id: Option<String>,
status: LocalShellStatus,
@@ -996,7 +997,7 @@ pub enum ResponseItem {
FunctionCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
id: Option<ResponseItemId>,
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<String>,
id: Option<ResponseItemId>,
call_id: Option<String>,
#[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<String>,
id: Option<ResponseItemId>,
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<String>,
id: Option<ResponseItemId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
status: Option<String>,
@@ -1066,7 +1067,7 @@ pub enum ResponseItem {
CustomToolCallOutput {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
id: Option<ResponseItemId>,
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<String>,
id: Option<ResponseItemId>,
call_id: Option<String>,
status: String,
execution: String,
@@ -1102,7 +1103,7 @@ pub enum ResponseItem {
WebSearchCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
id: Option<ResponseItemId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
status: Option<String>,
@@ -1125,7 +1126,7 @@ pub enum ResponseItem {
ImageGenerationCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<String>,
id: Option<ResponseItemId>,
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<String>,
id: Option<ResponseItemId>,
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<String>,
id: Option<ResponseItemId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
encrypted_content: Option<String>,
@@ -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<String>) {
pub fn set_id(&mut self, new_id: Option<ResponseItemId>) {
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()),
),
+3 -2
View File
@@ -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<Vec<UserInput>> for Op {
pub struct InterAgentCommunication {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub id: Option<String>,
pub id: Option<ResponseItemId>,
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")],
+70
View File
@@ -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<str> 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<ResponseItemId> for String {
fn from(value: ResponseItemId) -> Self {
value.0
}
}
#[cfg(test)]
#[path = "response_item_id_tests.rs"]
mod tests;
@@ -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}");
}
}
+3 -2
View File
@@ -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",