From 4a82ecc3c9fcb8b9f21d5144c40a0dfdab54a02a Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 22 Jun 2026 16:45:14 -0700 Subject: [PATCH] feat(core): store turn_id on ResponseItem metadata (#28360) ## Description This PR is a followup to https://github.com/openai/codex/pull/28355 and starts assigning `internal_chat_message_metadata_passthrough.turn_id` to durable Responses API items created during a turn. The goal is that those items keep the `turn_id` that introduced them when Codex resends stateless HTTP context, reconstructs history for resume/fork paths, or reuses websocket response state. ## What changed - Set `internal_chat_message_metadata_passthrough.turn_id` when missing as response items enter durable history, initial/replacement history, inter-agent communication history, and local compaction summaries. - Preserve existing item turn IDs instead of overwriting them during persistence, resume reconstruction, compaction, forked history, and websocket incremental reuse. - Keep `compaction_trigger` fieldless because it is a request control, not a durable response item. - Update focused history/request assertions and fixtures for stateless requests, websocket incrementals, compaction, thread injection, prompt debug, and related CI coverage. --- .../schema/json/ClientRequest.json | 10 --- .../codex_app_server_protocol.schemas.json | 10 --- .../codex_app_server_protocol.v2.schemas.json | 10 --- .../RawResponseItemCompletedNotification.json | 10 --- .../schema/json/v2/ThreadResumeParams.json | 10 --- .../schema/typescript/ResponseItem.ts | 2 +- .../tests/suite/v2/thread_inject_items.rs | 2 +- .../app-server/tests/suite/v2/web_search.rs | 8 +- codex-rs/core/src/agent/control_tests.rs | 24 +++--- codex-rs/core/src/compact.rs | 5 ++ codex-rs/core/src/compact_remote_v2.rs | 4 +- codex-rs/core/src/session/mod.rs | 11 ++- codex-rs/core/src/session/tests.rs | 70 +++++++++++++++- .../src/tools/handlers/extension_tools.rs | 6 +- codex-rs/core/src/tools/router_tests.rs | 4 +- .../core/tests/common/context_snapshot.rs | 2 +- codex-rs/core/tests/common/responses.rs | 41 ++++++++- codex-rs/core/tests/suite/agent_websocket.rs | 5 +- codex-rs/core/tests/suite/client.rs | 62 +++++++++++++- .../core/tests/suite/client_websockets.rs | 9 +- .../core/tests/suite/compact_remote_parity.rs | 1 + codex-rs/core/tests/suite/image_rollout.rs | 4 +- codex-rs/core/tests/suite/prompt_caching.rs | 41 ++++++--- .../core/tests/suite/prompt_debug_tests.rs | 6 +- .../tests/suite/subagent_notifications.rs | 13 +-- codex-rs/protocol/src/models.rs | 84 ++++++------------- codex-rs/protocol/src/protocol.rs | 30 ++++++- 27 files changed, 317 insertions(+), 167 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index e1399a2dd..28597eff6 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2922,16 +2922,6 @@ }, { "properties": { - "internal_chat_message_metadata_passthrough": { - "anyOf": [ - { - "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" 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 810b8541d..1b9719f98 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 @@ -15594,16 +15594,6 @@ }, { "properties": { - "internal_chat_message_metadata_passthrough": { - "anyOf": [ - { - "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" 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 de7d46aac..4ade0622c 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 @@ -11998,16 +11998,6 @@ }, { "properties": { - "internal_chat_message_metadata_passthrough": { - "anyOf": [ - { - "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" 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 d06d821a7..01ac63c26 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -1003,16 +1003,6 @@ }, { "properties": { - "internal_chat_message_metadata_passthrough": { - "anyOf": [ - { - "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" 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 4ce88a116..bc9cb524d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1074,16 +1074,6 @@ }, { "properties": { - "internal_chat_message_metadata_passthrough": { - "anyOf": [ - { - "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 074e11a16..62547d3e4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -20,4 +20,4 @@ id?: string, /** * 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, 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", internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "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?: 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, 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" }; diff --git a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs index 00b90e64d..aa4908a37 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -86,7 +86,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu resumed_history .history .iter() - .any(|item| matches!(item, RolloutItem::ResponseItem(response_item) if response_item == &injected_item)), + .any(|item| matches!(item, RolloutItem::ResponseItem(response_item) if responses::strip_metadata(response_item.clone()) == injected_item)), "injected item should be persisted in rollout history" ); diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index 626767132..f8c115d74 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -156,8 +156,10 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { search_body["input"] .as_array() .context("search input should be an array")? - .last(), - Some(&json!({ + .last() + .cloned() + .map(responses::strip_metadata_from_json), + Some(json!({ "type": "message", "role": "user", "content": [{"type": "input_text", "text": "Search the web"}], @@ -165,7 +167,7 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { ); assert_eq!( - requests[1].function_call_output(call_id), + responses::strip_metadata_from_json(requests[1].function_call_output(call_id)), json!({ "type": "function_call_output", "call_id": call_id, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 17532bd78..e37e9b04d 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -887,6 +887,15 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { parent_thread .inject_user_message_without_turn("parent seed context".to_string()) .await; + let expected_parent_seed = parent_thread + .codex + .session + .clone_history() + .await + .raw_items() + .first() + .cloned() + .expect("parent seed should be recorded"); let turn_context = parent_thread.codex.session.new_default_turn().await; let parent_spawn_call_id = "spawn-call-history".to_string(); let trigger_message = InterAgentCommunication::new( @@ -988,17 +997,12 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { ); assert_ne!(child_thread_id, parent_thread_id); let history = child_thread.codex.session.clone_history().await; + let mut expected_final_answer = + assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)); + expected_final_answer.set_turn_id_if_missing(&turn_context.sub_id); let expected_history = [ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "parent seed context".to_string(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, - assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), + expected_parent_seed, + expected_final_answer, ResponseItem::Message { id: None, role: "developer".to_string(), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 94c26b8ad..c60bd155e 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -302,6 +302,11 @@ async fn run_compact_task_inner_impl( let user_messages = collect_user_messages(history_items); let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text); + if let Some(summary_item) = new_history.last_mut() { + // This replacement history skips `record_conversation_items`; only the appended summary + // belongs to this compaction turn. + summary_item.set_turn_id_if_missing(&turn_context.sub_id); + } let (window_number, window_ids) = sess.advance_auto_compact_window().await; if matches!( diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 0778c7472..54d139a0e 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -234,9 +234,7 @@ async fn run_remote_compact_task_inner_impl( ) .await?; let mut input = prompt_input.clone(); - input.push(ResponseItem::CompactionTrigger { - internal_chat_message_metadata_passthrough: None, - }); + input.push(ResponseItem::CompactionTrigger {}); let prompt = Prompt { input, tools: tool_router.model_visible_specs(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 4a7c7a9e4..1bd5bae8b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2693,6 +2693,10 @@ impl Session { ) -> Cow<'a, [ResponseItem]> { let mut items = Cow::Borrowed(items); prepare_response_items(items.to_mut()); + // Most response items get their passthrough turn ID at the durable history boundary. + for item in items.to_mut() { + item.set_turn_id_if_missing(&turn_context.sub_id); + } if turn_context.config.features.enabled(Feature::ItemIds) { Self::assign_missing_response_item_ids(items) } else { @@ -2784,8 +2788,9 @@ impl Session { pub(crate) async fn record_inter_agent_communication( &self, turn_context: &TurnContext, - communication: InterAgentCommunication, + mut communication: InterAgentCommunication, ) { + communication.set_turn_id_if_missing(&turn_context.sub_id); let response_item = communication.to_model_input_item(); let items = self.prepare_conversation_items_for_history( turn_context, @@ -3355,6 +3360,10 @@ impl Session { { items.push(guardian_developer_message); } + // New context windows and compaction install these items directly into replacement history. + for item in &mut items { + item.set_turn_id_if_missing(&turn_context.sub_id); + } items } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index ff71775aa..378dff58c 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -152,6 +152,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::responses::strip_metadata_from_items; use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::test_path_buf; @@ -1692,6 +1693,67 @@ async fn record_initial_history_reconstructs_resumed_transcript() { assert_eq!(expected, history.raw_items()); } +#[tokio::test] +async fn record_conversation_items_stamps_missing_turn_id_and_preserves_existing_turn_id() { + let (session, turn_context) = make_session_and_context().await; + let fresh_item = user_message("fresh"); + let mut existing_item = assistant_message("existing"); + existing_item.set_turn_id_if_missing("older-turn"); + + session + .record_conversation_items(&turn_context, &[fresh_item.clone(), existing_item.clone()]) + .await; + + let mut expected_fresh_item = fresh_item; + expected_fresh_item.set_turn_id_if_missing(&turn_context.sub_id); + let expected_items = vec![expected_fresh_item, existing_item]; + assert_eq!( + session.clone_history().await.raw_items(), + expected_items.as_slice() + ); +} + +#[tokio::test] +async fn record_inter_agent_communication_sets_turn_id_in_rollout_and_resume() { + let (mut session, turn_context) = make_session_and_context().await; + let rollout_path = attach_thread_persistence(&mut session).await; + let communication = InterAgentCommunication::new( + AgentPath::root().join("worker").expect("worker path"), + AgentPath::root(), + Vec::new(), + "child done".to_string(), + /*trigger_turn*/ false, + ); + let mut expected_item = communication.to_model_input_item(); + expected_item.set_turn_id_if_missing(&turn_context.sub_id); + + session + .record_inter_agent_communication(&turn_context, communication) + .await; + + assert_eq!( + session.clone_history().await.raw_items(), + std::slice::from_ref(&expected_item) + ); + + session.flush_rollout().await.expect("rollout should flush"); + let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) + .await + .expect("read rollout history") + else { + panic!("expected resumed rollout history"); + }; + + let (resumed_session, _resumed_turn_context) = make_session_and_context().await; + resumed_session + .record_initial_history(InitialHistory::Resumed(resumed)) + .await; + assert_eq!( + resumed_session.clone_history().await.raw_items(), + std::slice::from_ref(&expected_item) + ); +} + #[tokio::test] async fn prepares_image_failures_before_history_insertion() { let (session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( @@ -1758,7 +1820,7 @@ async fn prepares_image_failures_before_history_insertion() { }, internal_chat_message_metadata_passthrough: None, }]; - assert_eq!(history.raw_items(), expected.as_slice()); + assert_eq!(strip_metadata_from_items(history.raw_items()), expected); } #[tokio::test] @@ -8224,7 +8286,7 @@ async fn handle_output_item_done_records_image_save_history_message() { ), ); let expected = vec![image_message, item]; - assert_eq!(history.raw_items(), expected.as_slice()); + assert_eq!(strip_metadata_from_items(history.raw_items()), expected); assert_eq!( std::fs::read(&expected_saved_path).expect("saved file"), b"foo" @@ -8267,7 +8329,7 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() { let history = session.clone_history().await; let expected = vec![item]; - assert_eq!(history.raw_items(), expected.as_slice()); + assert_eq!(strip_metadata_from_items(history.raw_items()), expected); assert!(!expected_saved_path.exists()); } @@ -9019,7 +9081,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() internal_chat_message_metadata_passthrough: None, }; assert!( - history.raw_items().iter().any(|item| item == &expected), + strip_metadata_from_items(history.raw_items()).contains(&expected), "expected pending input to be persisted into history on turn completion" ); diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 808a9c123..025cde231 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -334,11 +334,13 @@ mod tests { session .record_conversation_items(&turn, std::slice::from_ref(&history_item)) .await; + let mut expected_history_item = history_item.clone(); + expected_history_item.set_turn_id_if_missing(&turn_id); let raw_history_event = rx.recv().await.expect("history raw response item event"); let EventMsg::RawResponseItem(raw_history_item) = raw_history_event.msg else { panic!("expected raw response item event"); }; - assert_eq!(raw_history_item.item, history_item); + assert_eq!(raw_history_item.item, expected_history_item); let invocation = ToolInvocation { session, turn, @@ -377,7 +379,7 @@ mod tests { ); assert_eq!( captured_call.conversation_history.items(), - std::slice::from_ref(&history_item) + std::slice::from_ref(&expected_history_item) ); match captured_call.payload { ToolPayload::Function { arguments } => { diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 9cf237477..77ddfe48d 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -345,6 +345,8 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow session .record_conversation_items(&turn, std::slice::from_ref(&history_item)) .await; + let mut expected_history_item = history_item.clone(); + expected_history_item.set_turn_id_if_missing(&turn.sub_id); let router = ToolRouter::from_turn_context( &turn, @@ -404,7 +406,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow json!({ "arguments": { "message": "hello" }, "callId": "call-extension", - "conversationHistory": [history_item], + "conversationHistory": [expected_history_item], "ok": true, }) ); diff --git a/codex-rs/core/tests/common/context_snapshot.rs b/codex-rs/core/tests/common/context_snapshot.rs index 06e153da3..1a236e1aa 100644 --- a/codex-rs/core/tests/common/context_snapshot.rs +++ b/codex-rs/core/tests/common/context_snapshot.rs @@ -266,7 +266,7 @@ fn format_request_body_snapshot( request: &ResponsesRequest, options: &ContextSnapshotOptions, ) -> String { - let mut body = request.body_json(); + let mut body = crate::responses::strip_metadata_from_json(request.body_json()); canonicalize_json_snapshot_value(&mut body, options); serde_json::to_string_pretty(&body).expect("request body should serialize") } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 10abf64b6..6a605a80b 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -98,6 +98,35 @@ fn decode_body_bytes(body: &[u8], content_encoding: Option<&str>) -> Vec { } } +/// Returns a response item without internal transport metadata for semantic assertions. +pub fn strip_metadata(mut item: ResponseItem) -> ResponseItem { + item.clear_internal_chat_message_metadata_passthrough(); + item +} + +/// Returns response items without internal transport metadata for semantic assertions. +pub fn strip_metadata_from_items(items: &[ResponseItem]) -> Vec { + items.iter().cloned().map(strip_metadata).collect() +} + +/// Returns JSON without internal transport metadata for semantic assertions. +pub fn strip_metadata_from_json(value: Value) -> Value { + match value { + Value::Array(values) => { + Value::Array(values.into_iter().map(strip_metadata_from_json).collect()) + } + Value::Object(mut map) => { + map.remove("internal_chat_message_metadata_passthrough"); + Value::Object( + map.into_iter() + .map(|(key, value)| (key, strip_metadata_from_json(value))) + .collect(), + ) + } + value => value, + } +} + impl ResponsesRequest { pub fn body_json(&self) -> Value { let body = decode_body_bytes( @@ -1092,11 +1121,17 @@ pub async fn mount_compact_user_history_with_summary_sequence( ) }) .collect::>(); - // Append a synthetic compaction item as the newest item. - output.push(serde_json::json!({ + let compaction_turn_id = body_json["client_metadata"]["turn_id"].as_str(); + // Match Responses API: generated compaction items inherit the compact request turn. + let mut compaction_item = serde_json::json!({ "type": "compaction", "encrypted_content": summary_text, - })); + }); + if let Some(turn_id) = compaction_turn_id { + compaction_item["internal_chat_message_metadata_passthrough"] = + serde_json::json!({ "turn_id": turn_id }); + } + output.push(compaction_item); ResponseTemplate::new(200) .insert_header("content-type", "application/json") .set_body_json(serde_json::json!({ "output": output })) diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index c405e3599..f3e104778 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -175,11 +175,14 @@ async fn websocket_v2_test_codex_shell_chain() -> Result<()> { skip_if_no_network!(Ok(())); let call_id = "shell-command-call"; + let mut shell_command_call = ev_shell_command_call(call_id, "echo websocket"); + shell_command_call["item"]["internal_chat_message_metadata_passthrough"] = + serde_json::json!({"turn_id": "turn-123"}); let server = start_websocket_server(vec![vec![ vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), - ev_shell_command_call(call_id, "echo websocket"), + shell_command_call, ev_completed("resp-1"), ], vec![ diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index d0e68c8a9..aa2026be1 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -66,6 +66,7 @@ use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::sse_failed; +use core_test_support::responses::strip_metadata_from_json; use core_test_support::responses_metadata as test_responses_metadata; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; @@ -185,6 +186,65 @@ fn assert_codex_client_metadata( ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn openai_stateless_responses_requests_preserve_item_turn_metadata_across_turns() { + let server = MockServer::start().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp1"), + ev_assistant_message("msg-1", "first answer"), + ev_completed("resp1"), + ]), + sse(vec![ev_response_created("resp2"), ev_completed("resp2")]), + ], + ) + .await; + let test = test_codex().build(&server).await.unwrap(); + + test.submit_turn("turn one").await.unwrap(); + test.submit_turn("turn two").await.unwrap(); + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let first = requests[0].body_json(); + let second = requests[1].body_json(); + let first_turn_id = first["client_metadata"]["turn_id"] + .as_str() + .expect("first request should include turn id"); + let second_turn_id = second["client_metadata"]["turn_id"] + .as_str() + .expect("second request should include turn id"); + assert_ne!(first_turn_id, second_turn_id); + + let first_input = first["input"].as_array().expect("first input"); + let second_input = second["input"].as_array().expect("second input"); + assert_eq!(&second_input[..first_input.len()], first_input.as_slice()); + for item in first_input { + assert_eq!( + item["internal_chat_message_metadata_passthrough"]["turn_id"].as_str(), + Some(first_turn_id) + ); + } + + let item_turn_id = |text: &str| { + second_input + .iter() + .find(|item| { + item["content"].as_array().is_some_and(|content| { + content + .iter() + .any(|content_item| content_item["text"].as_str() == Some(text)) + }) + }) + .and_then(|item| item["internal_chat_message_metadata_passthrough"]["turn_id"].as_str()) + }; + assert_eq!(item_turn_id("turn one"), Some(first_turn_id)); + assert_eq!(item_turn_id("first answer"), Some(first_turn_id)); + assert_eq!(item_turn_id("turn two"), Some(second_turn_id)); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn non_openai_responses_requests_omit_item_passthrough_metadata() { let server = MockServer::start().await; @@ -3612,7 +3672,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { let tail_len = r3_tail_expected.as_array().unwrap().len(); let actual_tail = &r3_input_array[r3_input_array.len() - tail_len..]; assert_eq!( - serde_json::Value::Array(actual_tail.to_vec()), + strip_metadata_from_json(serde_json::Value::Array(actual_tail.to_vec())), r3_tail_expected, "request 3 tail mismatch", ); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 5d1721ee7..d35714ea4 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1564,10 +1564,13 @@ 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"); + first_output_item["item"]["internal_chat_message_metadata_passthrough"] = + json!({"turn_id": "turn-123"}); let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "assistant output"), + first_output_item, ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], @@ -1577,9 +1580,11 @@ 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"); + prior_assistant_output.set_turn_id_if_missing("turn-123"); let prompt_two = prompt_with_input(vec![ message_item("hello"), - assistant_message_item("msg-1", "assistant output"), + prior_assistant_output, message_item("second"), ]); let first_responses_metadata = turn_metadata(&harness, Some("turn-123")); diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 432e5815a..6f56ce016 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -924,6 +924,7 @@ fn normalize_value(value: Value) -> Value { Value::Array(values) => Value::Array(values.into_iter().map(normalize_value).collect()), Value::Object(map) => Value::Object( map.into_iter() + .filter(|(key, _value)| key != "internal_chat_message_metadata_passthrough") .map(|(key, value)| (key, normalize_value(value))) .collect(), ), diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index f644f3340..d84a5501c 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -181,7 +181,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu internal_chat_message_metadata_passthrough: None, }; - assert_eq!(actual, expected); + assert_eq!(responses::strip_metadata(actual), expected); Ok(()) } @@ -272,7 +272,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> internal_chat_message_metadata_passthrough: None, }; - assert_eq!(actual, expected); + assert_eq!(responses::strip_metadata(actual), expected); Ok(()) } diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index a45551232..90d48d77a 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -25,6 +25,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::responses::strip_metadata_from_json; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local_selections; @@ -54,6 +55,13 @@ fn text_user_input_parts(texts: Vec) -> serde_json::Value { }) } +fn assert_eq_without_metadata(left: serde_json::Value, right: serde_json::Value) { + assert_eq!( + strip_metadata_from_json(left), + strip_metadata_from_json(right) + ); +} + fn assert_default_env_context(text: &str, cwd: &str) { assert_env_context_fragment(text); assert!( @@ -384,16 +392,18 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests Some("input_text"), "expected environment context bundled after UI message in cached contextual message" ); - assert_eq!(input1[2], text_user_input("hello 1".to_string())); + assert_eq_without_metadata(input1[2].clone(), text_user_input("hello 1".to_string())); let body2 = req2.single_request().body_json(); let input2 = body2["input"].as_array().expect("input array"); - assert_eq!( - &input2[..input1.len()], - input1.as_slice(), - "expected cached prefix to be reused" + assert_eq_without_metadata( + serde_json::Value::Array(input2[..input1.len()].to_vec()), + serde_json::Value::Array(input1.to_vec()), + ); + assert_eq_without_metadata( + input2[input1.len()].clone(), + text_user_input("hello 2".to_string()), ); - assert_eq!(input2[input1.len()], text_user_input("hello 2".to_string())); Ok(()) } @@ -523,7 +533,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an expected_body2.push(expected_permissions_msg_2); expected_body2.push(expected_env_msg_2); expected_body2.push(expected_user_message_2); - assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); + assert_eq_without_metadata( + body2["input"].clone(), + serde_json::Value::Array(expected_body2), + ); Ok(()) } @@ -810,7 +823,10 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res expected_body2.push(expected_settings_update_msg); expected_body2.push(expected_env_msg_2); expected_body2.push(expected_user_message_2); - assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); + assert_eq_without_metadata( + body2["input"].clone(), + serde_json::Value::Array(expected_body2), + ); Ok(()) } @@ -818,7 +834,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - use pretty_assertions::assert_eq; let server = start_mock_server().await; let req1 = mount_sse_once( @@ -940,7 +955,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a expected_contextual_user_msg_1.clone(), expected_user_message_1.clone(), ]); - assert_eq!(body1["input"], expected_input_1); + assert_eq_without_metadata(body1["input"].clone(), expected_input_1); let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ @@ -949,7 +964,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a expected_user_message_1, expected_user_message_2, ]); - assert_eq!(body2["input"], expected_input_2); + assert_eq_without_metadata(body2["input"].clone(), expected_input_2); Ok(()) } @@ -1079,7 +1094,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu expected_contextual_user_msg_1.clone(), expected_user_message_1.clone(), ]); - assert_eq!(body1["input"], expected_input_1); + assert_eq_without_metadata(body1["input"].clone(), expected_input_1); let body1_input = body1["input"].as_array().expect("input array"); let expected_settings_update_msg = body2["input"][body1_input.len()].clone(); @@ -1118,7 +1133,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu expected_env_update_msg, expected_user_message_2, ]); - assert_eq!(body2["input"], expected_input_2); + assert_eq_without_metadata(body2["input"].clone(), expected_input_2); Ok(()) } diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index 0628f8ff9..74aeece04 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -8,6 +8,7 @@ use codex_home::CodexHomeUserInstructionsProvider; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; +use core_test_support::responses::strip_metadata; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -51,7 +52,10 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { phase: None, internal_chat_message_metadata_passthrough: None, }; - assert_eq!(input.last(), Some(&expected_user_message)); + assert_eq!( + input.last().cloned().map(strip_metadata), + Some(expected_user_message) + ); assert!(input.iter().any(|item| { let ResponseItem::Message { content, .. } = item else { return false; diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index b24f1c165..c49e47496 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -28,6 +28,7 @@ use core_test_support::responses::namespace_child_tool; use core_test_support::responses::sse; use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; +use core_test_support::responses::strip_metadata_from_json; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local_selections; @@ -1085,8 +1086,8 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result .pop() .expect("child request"); assert_eq!( - child_request.inputs_of_type("agent_message"), - vec![json!({ + strip_metadata_from_json(Value::Array(child_request.inputs_of_type("agent_message"))), + Value::Array(vec![json!({ "type": "agent_message", "author": "/root", "recipient": "/root/worker", @@ -1100,7 +1101,7 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result "encrypted_content": encrypted_message, }, ], - })] + })]) ); Ok(()) @@ -1228,8 +1229,8 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( .pop() .expect("agent message request"); assert_eq!( - request.inputs_of_type("agent_message"), - vec![json!({ + strip_metadata_from_json(Value::Array(request.inputs_of_type("agent_message"))), + Value::Array(vec![json!({ "type": "agent_message", "author": "/root/worker", "recipient": "/root", @@ -1237,7 +1238,7 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( "type": "input_text", "text": notification, }], - })] + })]) ); Ok(()) diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index f7d909950..e107013a7 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -915,6 +915,20 @@ pub struct InternalChatMessageMetadataPassthrough { pub turn_id: Option, } +impl InternalChatMessageMetadataPassthrough { + pub(crate) fn set_turn_id_if_missing(metadata: &mut Option, turn_id: &str) { + if turn_id.is_empty() + || metadata + .as_ref() + .and_then(|metadata| metadata.turn_id.as_deref()) + .is_some_and(|turn_id| !turn_id.is_empty()) + { + return; + } + metadata.get_or_insert_with(Self::default).turn_id = Some(turn_id.to_string()); + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { @@ -1120,13 +1134,8 @@ pub enum ResponseItem { #[ts(optional)] internal_chat_message_metadata_passthrough: Option, }, - // Compaction triggers are request controls, and the Responses API does not - // accept an `id` field for them. - CompactionTrigger { - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - internal_chat_message_metadata_passthrough: Option, - }, + // Compaction triggers are request controls, not durable response items. + CompactionTrigger {}, ContextCompaction { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1198,16 +1207,11 @@ impl ResponseItem { } /// Stamps the item with `turn_id` unless it already has a non-empty turn ID. - pub fn stamp_turn_id_if_missing(&mut self, turn_id: &str) { - if turn_id.is_empty() || self.turn_id().is_some() { - return; - } + pub fn set_turn_id_if_missing(&mut self, turn_id: &str) { let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() else { return; }; - metadata - .get_or_insert_with(InternalChatMessageMetadataPassthrough::default) - .turn_id = Some(turn_id.to_string()); + InternalChatMessageMetadataPassthrough::set_turn_id_if_missing(metadata, turn_id); } /// Removes internal chat message metadata passthrough before sending to a provider that does @@ -1274,15 +1278,11 @@ impl ResponseItem { internal_chat_message_metadata_passthrough: metadata, .. } - | Self::CompactionTrigger { - internal_chat_message_metadata_passthrough: metadata, - .. - } | Self::ContextCompaction { internal_chat_message_metadata_passthrough: metadata, .. } => metadata.as_ref(), - Self::Other => None, + Self::CompactionTrigger { .. } | Self::Other => None, } } @@ -1342,15 +1342,11 @@ impl ResponseItem { internal_chat_message_metadata_passthrough: metadata, .. } - | Self::CompactionTrigger { - internal_chat_message_metadata_passthrough: metadata, - .. - } | Self::ContextCompaction { internal_chat_message_metadata_passthrough: metadata, .. } => Some(metadata), - Self::Other => None, + Self::CompactionTrigger { .. } | Self::Other => None, } } } @@ -2206,23 +2202,23 @@ mod tests { }))?; assert_eq!(unknown_metadata, item); - item.stamp_turn_id_if_missing("turn-2"); + item.set_turn_id_if_missing("turn-2"); assert_eq!(item.turn_id(), Some("turn-1")); let mut empty_turn_id = response_item_with_passthrough_metadata(Some(passthrough_metadata(""))); - empty_turn_id.stamp_turn_id_if_missing("turn-1"); + empty_turn_id.set_turn_id_if_missing("turn-1"); assert_eq!(empty_turn_id.turn_id(), Some("turn-1")); let mut missing_turn_id = response_item_with_passthrough_metadata( /*internal_chat_message_metadata_passthrough*/ None, ); - missing_turn_id.stamp_turn_id_if_missing(""); - missing_turn_id.stamp_turn_id_if_missing("turn-1"); + missing_turn_id.set_turn_id_if_missing(""); + missing_turn_id.set_turn_id_if_missing("turn-1"); assert_eq!(missing_turn_id.turn_id(), Some("turn-1")); let mut other = ResponseItem::Other; - other.stamp_turn_id_if_missing("turn-1"); + other.set_turn_id_if_missing("turn-1"); assert_eq!(other.turn_id(), None); Ok(()) } @@ -3111,9 +3107,7 @@ mod tests { #[test] fn serializes_compaction_trigger_without_payload() -> Result<()> { - let item = ResponseItem::CompactionTrigger { - internal_chat_message_metadata_passthrough: None, - }; + let item = ResponseItem::CompactionTrigger {}; assert_eq!( serde_json::to_value(item)?, @@ -3124,37 +3118,13 @@ mod tests { Ok(()) } - #[test] - fn serializes_stamped_compaction_trigger_passthrough_metadata() -> Result<()> { - let mut item = ResponseItem::CompactionTrigger { - internal_chat_message_metadata_passthrough: None, - }; - item.stamp_turn_id_if_missing("turn-1"); - - assert_eq!( - serde_json::to_value(item)?, - serde_json::json!({ - "type": "compaction_trigger", - "internal_chat_message_metadata_passthrough": { - "turn_id": "turn-1", - }, - }) - ); - Ok(()) - } - #[test] fn deserializes_compaction_trigger_without_payload() -> Result<()> { let json = r#"{"type":"compaction_trigger"}"#; let item: ResponseItem = serde_json::from_str(json)?; - assert_eq!( - item, - ResponseItem::CompactionTrigger { - internal_chat_message_metadata_passthrough: None, - } - ); + assert_eq!(item, ResponseItem::CompactionTrigger {}); Ok(()) } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 600aa475b..5a73ac4c9 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -38,6 +38,7 @@ use crate::models::AgentMessageInputContent; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::ImageDetail; +use crate::models::InternalChatMessageMetadataPassthrough; use crate::models::MessagePhase; use crate::models::PermissionProfile; use crate::models::ResponseInputItem; @@ -698,6 +699,9 @@ pub struct InterAgentCommunication { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub encrypted_content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub internal_chat_message_metadata_passthrough: Option, pub trigger_turn: bool, } @@ -715,6 +719,7 @@ impl InterAgentCommunication { other_recipients, content, encrypted_content: None, + internal_chat_message_metadata_passthrough: None, trigger_turn, } } @@ -732,15 +737,25 @@ impl InterAgentCommunication { other_recipients, content: String::new(), encrypted_content: Some(encrypted_content), + internal_chat_message_metadata_passthrough: None, trigger_turn, } } + pub fn set_turn_id_if_missing(&mut self, turn_id: &str) { + InternalChatMessageMetadataPassthrough::set_turn_id_if_missing( + &mut self.internal_chat_message_metadata_passthrough, + turn_id, + ); + } + pub fn to_response_input_item(&self) -> ResponseInputItem { + let mut communication = self.clone(); + communication.internal_chat_message_metadata_passthrough = None; ResponseInputItem::Message { role: "assistant".to_string(), content: vec![ContentItem::OutputText { - text: serde_json::to_string(self).unwrap_or_default(), + text: serde_json::to_string(&communication).unwrap_or_default(), }], phase: Some(MessagePhase::Commentary), } @@ -775,7 +790,9 @@ impl InterAgentCommunication { author: self.author.to_string(), recipient: self.recipient.to_string(), content, - internal_chat_message_metadata_passthrough: None, + internal_chat_message_metadata_passthrough: self + .internal_chat_message_metadata_passthrough + .clone(), } } @@ -4339,21 +4356,26 @@ mod tests { #[test] fn inter_agent_communication_response_input_item_preserves_commentary_phase() { - let communication = InterAgentCommunication { + let mut communication = InterAgentCommunication { author: AgentPath::root(), recipient: AgentPath::root().join("reviewer").expect("recipient path"), other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], content: "review the diff".to_string(), encrypted_content: None, + internal_chat_message_metadata_passthrough: None, trigger_turn: true, }; + communication.set_turn_id_if_missing("turn-1"); + let mut serialized_communication = communication.clone(); + serialized_communication.internal_chat_message_metadata_passthrough = None; assert_eq!( communication.to_response_input_item(), ResponseInputItem::Message { role: "assistant".to_string(), content: vec![ContentItem::OutputText { - text: serde_json::to_string(&communication).expect("serialize communication"), + text: serde_json::to_string(&serialized_communication) + .expect("serialize communication"), }], phase: Some(MessagePhase::Commentary), }