From 6fe352a5fbe0a75b642c5c991b4af2533988a45e Mon Sep 17 00:00:00 2001 From: Roy Han Date: Fri, 20 Mar 2026 14:19:33 -0700 Subject: [PATCH] denormalize sandbox and support input/output uuid collapse --- codex-rs/core/src/codex.rs | 54 +++--- codex-rs/core/src/codex_tests.rs | 8 +- codex-rs/core/src/stream_events_utils.rs | 32 ++-- codex-rs/core/src/tools/code_mode/worker.rs | 1 + codex-rs/core/src/tools/context.rs | 6 + codex-rs/core/src/tools/context_tests.rs | 9 +- codex-rs/core/src/tools/handlers/plan.rs | 1 + .../core/src/tools/handlers/view_image.rs | 1 + codex-rs/core/src/tools/js_repl/mod_tests.rs | 2 + codex-rs/core/src/tools/parallel.rs | 3 + codex-rs/core/tests/suite/client.rs | 146 ++++++++++++++- codex-rs/protocol/src/models.rs | 168 +++++++++++++++++- 12 files changed, 381 insertions(+), 50 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c5e476bcf3..99152e29bb 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1071,6 +1071,17 @@ fn session_source_metadata_patch(session_source: &SessionSource) -> ResponseItem } } +fn base_item_metadata_patch(turn_context: &TurnContext) -> ResponseItemMetadata { + let mut patch = ResponseItemMetadata { + sandbox_policy: Some(sandbox_policy_to_metadata( + turn_context.sandbox_policy.get(), + )), + ..ResponseItemMetadata::default() + }; + patch.merge_from(session_source_metadata_patch(&turn_context.session_source)); + patch +} + fn response_item_tool_call_id(item: &ResponseItem) -> Option<&str> { match item { ResponseItem::LocalShellCall { @@ -1146,7 +1157,6 @@ struct ToolApprovalMetadataSnapshot { } fn stamp_tool_approval_metadata_with_snapshot( - turn_context: &TurnContext, response_item: ResponseItem, snapshot: Option<&ToolApprovalMetadataSnapshot>, ) -> ResponseItem { @@ -1164,9 +1174,6 @@ fn stamp_tool_approval_metadata_with_snapshot( Some(metadata) => metadata, None => return response_item, }; - metadata.sandbox_policy = Some(sandbox_policy_to_metadata( - turn_context.sandbox_policy.get(), - )); match outcome { Some(outcome) => { @@ -3458,7 +3465,7 @@ impl Session { pub(crate) async fn stamp_tool_approval_metadata( &self, - turn_context: &TurnContext, + _turn_context: &TurnContext, response_item: ResponseItem, ) -> ResponseItem { if !self.enabled(Feature::ItemMetadata) { @@ -3477,12 +3484,12 @@ impl Session { pending_approval_call_ids, } }; - stamp_tool_approval_metadata_with_snapshot(turn_context, response_item, Some(&snapshot)) + stamp_tool_approval_metadata_with_snapshot(response_item, Some(&snapshot)) } pub(crate) async fn stamp_tool_approval_metadata_on_items( &self, - turn_context: &TurnContext, + _turn_context: &TurnContext, response_items: Vec, ) -> Vec { if !self.enabled(Feature::ItemMetadata) { @@ -3504,9 +3511,7 @@ impl Session { response_items .into_iter() - .map(|item| { - stamp_tool_approval_metadata_with_snapshot(turn_context, item, Some(&snapshot)) - }) + .map(|item| stamp_tool_approval_metadata_with_snapshot(item, Some(&snapshot))) .collect() } @@ -3548,7 +3553,7 @@ impl Session { turn_context: &TurnContext, items: &[ResponseItem], ) { - let items = self.prepare_history_items(items); + let items = self.prepare_history_items(items, turn_context); self.record_into_history_prepared(&items, turn_context) .await; self.persist_rollout_response_items(&items).await; @@ -3561,17 +3566,25 @@ impl Session { items: &[ResponseItem], turn_context: &TurnContext, ) { - let items = self.prepare_history_items(items); + let items = self.prepare_history_items(items, turn_context); self.record_into_history_prepared(&items, turn_context) .await; } - fn prepare_history_items(&self, items: &[ResponseItem]) -> Vec { + fn prepare_history_items( + &self, + items: &[ResponseItem], + turn_context: &TurnContext, + ) -> Vec { if self.enabled(Feature::ItemMetadata) { + let metadata_patch = base_item_metadata_patch(turn_context); items .iter() .cloned() .map(ResponseItem::with_generated_metadata_uuid) + .map(|item| { + stamp_response_item_metadata_on_response_item(item, metadata_patch.clone()) + }) .collect() } else { items.to_vec() @@ -4043,14 +4056,6 @@ impl Session { turn_context: &TurnContext, response_item: ResponseItem, ) { - let response_item = if self.enabled(Feature::ItemMetadata) { - stamp_response_item_metadata_on_response_item( - response_item, - session_source_metadata_patch(&turn_context.session_source), - ) - } else { - response_item - }; let response_item = self .stamp_tool_approval_metadata(turn_context, response_item) .await; @@ -4074,12 +4079,7 @@ impl Session { message_metadata: Option, ) { let metadata_patch = if self.enabled(Feature::ItemMetadata) { - let mut patch = message_metadata.unwrap_or_default(); - patch.sandbox_policy = Some(sandbox_policy_to_metadata( - turn_context.sandbox_policy.get(), - )); - patch.merge_from(session_source_metadata_patch(&turn_context.session_source)); - Some(patch) + Some(message_metadata.unwrap_or_default()) } else { None }; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 662afe229e..2c73528a44 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4402,6 +4402,7 @@ async fn task_finish_emits_prompt_queued_metadata_for_injected_user_input_when_f #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn record_response_item_stamps_session_source_on_all_supported_variants() { let (mut sess, tc, _rx) = make_session_and_context_with_rx().await; + let expected_sandbox_policy = sandbox_policy_to_metadata(tc.sandbox_policy.get()); Arc::get_mut(&mut sess) .expect("session should be uniquely owned in this test") .features @@ -4589,7 +4590,10 @@ async fn record_response_item_stamps_session_source_on_all_supported_variants() | ResponseItem::Compaction { metadata: Some(metadata), .. - } => metadata.session_source == Some(expected_session_source.clone()), + } => { + metadata.session_source == Some(expected_session_source.clone()) + && metadata.sandbox_policy == Some(expected_sandbox_policy.clone()) + } _ => false, }) .count(); @@ -4815,6 +4819,7 @@ async fn tool_call_metadata_stamps_non_escalated_false_when_feature_enabled() { async fn handle_output_item_done_stamps_tool_call_metadata_when_feature_enabled() { let (mut sess, tc, _rx) = make_session_and_context_with_rx().await; let expected_sandbox_policy = sandbox_policy_to_metadata(tc.sandbox_policy.get()); + let expected_session_source = session_source_to_metadata(&tc.session_source); Arc::get_mut(&mut sess) .expect("session should be uniquely owned in this test") .features @@ -4865,6 +4870,7 @@ async fn handle_output_item_done_stamps_tool_call_metadata_when_feature_enabled( && metadata.is_tool_call_escalated == Some(false) && metadata.review_decision.is_none() && metadata.sandbox_policy == Some(expected_sandbox_policy.clone()) + && metadata.session_source == Some(expected_session_source.clone()) ) })); } diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 49019cdf83..10e6b07445 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -251,6 +251,7 @@ pub(crate) async fn handle_output_item_done( body: FunctionCallOutputBody::Text(msg.to_string()), ..Default::default() }, + metadata: None, }; record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item) .await; @@ -273,6 +274,7 @@ pub(crate) async fn handle_output_item_done( body: FunctionCallOutputBody::Text(message), ..Default::default() }, + metadata: None, }; record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item) .await; @@ -382,29 +384,36 @@ pub(crate) fn last_assistant_message_from_item( pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Option { match input { - ResponseInputItem::FunctionCallOutput { call_id, output } => { - Some(ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output: output.clone(), - metadata: None, - }) - } + ResponseInputItem::FunctionCallOutput { + call_id, + output, + metadata, + } => Some(ResponseItem::FunctionCallOutput { + call_id: call_id.clone(), + output: output.clone(), + metadata: metadata.clone(), + }), ResponseInputItem::CustomToolCallOutput { call_id, name, output, + metadata, } => Some(ResponseItem::CustomToolCallOutput { call_id: call_id.clone(), name: name.clone(), output: output.clone(), - metadata: None, + metadata: metadata.clone(), }), - ResponseInputItem::McpToolCallOutput { call_id, output } => { + ResponseInputItem::McpToolCallOutput { + call_id, + output, + metadata, + } => { let output = output.as_function_call_output_payload(); Some(ResponseItem::FunctionCallOutput { call_id: call_id.clone(), output, - metadata: None, + metadata: metadata.clone(), }) } ResponseInputItem::ToolSearchOutput { @@ -412,12 +421,13 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti status, execution, tools, + metadata, } => Some(ResponseItem::ToolSearchOutput { call_id: Some(call_id.clone()), status: status.clone(), execution: execution.clone(), tools: tools.clone(), - metadata: None, + metadata: metadata.clone(), }), _ => None, } diff --git a/codex-rs/core/src/tools/code_mode/worker.rs b/codex-rs/core/src/tools/code_mode/worker.rs index 5853f3abe3..198acfeb5b 100644 --- a/codex-rs/core/src/tools/code_mode/worker.rs +++ b/codex-rs/core/src/tools/code_mode/worker.rs @@ -87,6 +87,7 @@ impl CodeModeProcess { call_id: notify.call_id.clone(), name: Some(PUBLIC_TOOL_NAME.to_string()), output: FunctionCallOutputPayload::from_text(notify.text), + metadata: None, }]) .await .is_err() diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 74efb0989b..236f830208 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -104,6 +104,7 @@ impl ToolOutput for CallToolResult { ResponseInputItem::McpToolCallOutput { call_id: call_id.to_string(), output: self.clone(), + metadata: None, } } @@ -151,6 +152,7 @@ impl ToolOutput for ToolSearchOutput { }) }) .collect(), + metadata: None, } } } @@ -254,10 +256,12 @@ impl ToolOutput for AbortedToolOutput { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), + metadata: None, }, ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput { call_id: call_id.to_string(), output: CallToolResult::from_error_text(self.message.clone()), + metadata: None, }, _ => function_tool_response( call_id, @@ -454,12 +458,14 @@ fn function_tool_response( call_id: call_id.to_string(), name: None, output: FunctionCallOutputPayload { body, success }, + metadata: None, }; } ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), output: FunctionCallOutputPayload { body, success }, + metadata: None, } } diff --git a/codex-rs/core/src/tools/context_tests.rs b/codex-rs/core/src/tools/context_tests.rs index 54bf2ec75b..9bf8524405 100644 --- a/codex-rs/core/src/tools/context_tests.rs +++ b/codex-rs/core/src/tools/context_tests.rs @@ -33,7 +33,9 @@ fn function_payloads_remain_function_outputs() { .to_response_item("fn-1", &payload); match response { - ResponseInputItem::FunctionCallOutput { call_id, output } => { + ResponseInputItem::FunctionCallOutput { + call_id, output, .. + } => { assert_eq!(call_id, "fn-1"); assert_eq!(output.content_items(), None); assert_eq!(output.body.to_text().as_deref(), Some("ok")); @@ -164,6 +166,7 @@ fn tool_search_payloads_roundtrip_as_tool_search_outputs() { status, execution, tools, + .. } => { assert_eq!(call_id, "search-1"); assert_eq!(status, "completed"); @@ -258,7 +261,9 @@ fn exec_command_tool_output_formats_truncated_response() { .to_response_item("call-42", &payload); match response { - ResponseInputItem::FunctionCallOutput { call_id, output } => { + ResponseInputItem::FunctionCallOutput { + call_id, output, .. + } => { assert_eq!(call_id, "call-42"); assert_eq!(output.success, Some(true)); let text = output diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index af8dc2c310..0b35d3b772 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -41,6 +41,7 @@ impl ToolOutput for PlanToolOutput { ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), output, + metadata: None, } } diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 9cbe9bbc76..917fd4cb1f 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -191,6 +191,7 @@ impl ToolOutput for ViewImageOutput { ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), output, + metadata: None, } } diff --git a/codex-rs/core/src/tools/js_repl/mod_tests.rs b/codex-rs/core/src/tools/js_repl/mod_tests.rs index ac109c117a..da57d046c4 100644 --- a/codex-rs/core/src/tools/js_repl/mod_tests.rs +++ b/codex-rs/core/src/tools/js_repl/mod_tests.rs @@ -249,6 +249,7 @@ fn summarize_tool_call_response_for_multimodal_function_output() { detail: None, }, ]), + metadata: None, }; let actual = JsReplManager::summarize_tool_call_response(&response); @@ -383,6 +384,7 @@ fn summarize_tool_call_response_for_multimodal_custom_output() { detail: None, }, ]), + metadata: None, }; let actual = JsReplManager::summarize_tool_call_response(&response); diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 0cc0989fb9..3c7ddd91d4 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -141,6 +141,7 @@ impl ToolCallRuntime { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), + metadata: None, }, ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput { call_id: call.call_id, @@ -149,6 +150,7 @@ impl ToolCallRuntime { body: codex_protocol::models::FunctionCallOutputBody::Text(message), success: Some(false), }, + metadata: None, }, _ => ResponseInputItem::FunctionCallOutput { call_id: call.call_id, @@ -156,6 +158,7 @@ impl ToolCallRuntime { body: codex_protocol::models::FunctionCallOutputBody::Text(message), success: Some(false), }, + metadata: None, }, } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 8ee00a63e2..ba13346e5e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -36,6 +36,7 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SessionMeta; @@ -58,6 +59,7 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; use dunce::canonicalize as normalize_path; use futures::StreamExt; use pretty_assertions::assert_eq; @@ -90,6 +92,33 @@ fn message_input_texts(item: &serde_json::Value) -> Vec<&str> { .collect() } +#[expect(clippy::unwrap_used)] +fn assistant_message_item_by_text<'a>( + input: &'a [serde_json::Value], + text: &str, +) -> &'a serde_json::Value { + input + .iter() + .find(|item| { + if item.get("type").and_then(serde_json::Value::as_str) != Some("message") + || item.get("role").and_then(serde_json::Value::as_str) != Some("assistant") + { + return false; + } + item.get("content") + .and_then(serde_json::Value::as_array) + .is_some_and(|content| { + content.iter().any(|entry| { + entry.get("type").and_then(serde_json::Value::as_str) == Some("output_text") + && entry.get("text").and_then(serde_json::Value::as_str) == Some(text) + }) + }) + }) + .unwrap_or_else(|| { + panic!("submitted assistant message input item not found for text: {text}") + }) +} + /// Writes an `auth.json` into the provided `codex_home` with the specified parameters. /// Returns the fake JWT string written to `tokens.id_token`. #[expect(clippy::unwrap_used)] @@ -634,7 +663,14 @@ async fn includes_conversation_id_and_model_headers_in_request() { ) .await; - let mut builder = test_codex().with_auth(CodexAuth::from_api_key("Test API Key")); + let mut builder = test_codex() + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(|config| { + config + .features + .enable(Feature::ItemMetadata) + .expect("feature flag should be enabled for this test"); + }); let test = builder .build(&server) .await @@ -2572,7 +2608,14 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { let request_log = mount_sse_sequence(&server, vec![sse1.clone(), sse1.clone(), sse1]).await; - let mut builder = test_codex().with_auth(CodexAuth::from_api_key("Test API Key")); + let mut builder = test_codex() + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(|config| { + config + .features + .enable(Feature::ItemMetadata) + .expect("feature flag should be enabled for this test"); + }); let codex = builder .build(&server) .await @@ -2669,3 +2712,102 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { "request 3 tail mismatch", ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn replayed_output_item_keeps_uuid_across_turns() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let sse_raw = r##"[ + {"type":"response.output_item.added", "item":{ + "type":"message", "role":"assistant", + "content":[{"type":"output_text","text":""}] + }}, + {"type":"response.output_text.delta", "delta":"Hey "}, + {"type":"response.output_text.delta", "delta":"there"}, + {"type":"response.output_text.delta", "delta":"!\n"}, + {"type":"response.output_item.done", "item":{ + "type":"message", "role":"assistant", + "content":[{"type":"output_text","text":"Hey there!\n"}] + }}, + {"type":"response.completed", "response": {"id": "__ID__"}} + ]"##; + let sse1 = core_test_support::load_sse_fixture_with_id_from_str(sse_raw, "resp1"); + let request_log = mount_sse_sequence(&server, vec![sse1.clone(), sse1]).await; + + let mut builder = test_codex() + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(|config| { + config + .features + .enable(Feature::ItemMetadata) + .expect("feature flag should be enabled for this test"); + }); + let codex = builder + .build(&server) + .await + .expect("create new conversation") + .codex; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "U1".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + let original_uuid = wait_for_event_match(&codex, |ev| match ev { + EventMsg::RawResponseItem(RawResponseItemEvent { + item: + ResponseItem::Message { + role, + content, + metadata: Some(metadata), + .. + }, + }) if role == "assistant" + && *content + == vec![ContentItem::OutputText { + text: "Hey there!\n".to_string(), + }] => + { + metadata.uuid.clone() + } + _ => None, + }) + .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "U2".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 2, "expected 2 requests (one per turn)"); + let request_2_input = requests[1] + .body_json() + .get("input") + .and_then(serde_json::Value::as_array) + .cloned() + .expect("request 2 missing input array"); + let replayed_uuid = assistant_message_item_by_text(&request_2_input, "Hey there!\n") + .get("metadata") + .and_then(serde_json::Value::as_object) + .and_then(|metadata| metadata.get("uuid")) + .and_then(serde_json::Value::as_str) + .expect("replayed assistant message should carry a uuid"); + + assert_eq!(replayed_uuid, original_uuid); +} diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 480883216f..7818c42e27 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -237,10 +237,16 @@ pub enum ResponseInputItem { #[ts(as = "FunctionCallOutputBody")] #[schemars(with = "FunctionCallOutputBody")] output: FunctionCallOutputPayload, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + metadata: Option, }, McpToolCallOutput { call_id: String, output: CallToolResult, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + metadata: Option, }, CustomToolCallOutput { call_id: String, @@ -250,6 +256,9 @@ pub enum ResponseInputItem { #[ts(as = "FunctionCallOutputBody")] #[schemars(with = "FunctionCallOutputBody")] output: FunctionCallOutputPayload, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + metadata: Option, }, ToolSearchOutput { call_id: String, @@ -257,6 +266,9 @@ pub enum ResponseInputItem { execution: String, #[ts(type = "unknown[]")] tools: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + metadata: Option, }, } @@ -1328,40 +1340,50 @@ impl From for ResponseItem { end_turn: None, phase: None, }, - ResponseInputItem::FunctionCallOutput { call_id, output } => Self::FunctionCallOutput { + ResponseInputItem::FunctionCallOutput { call_id, output, - metadata: None, + metadata, + } => Self::FunctionCallOutput { + call_id, + output, + metadata, }, - ResponseInputItem::McpToolCallOutput { call_id, output } => { + ResponseInputItem::McpToolCallOutput { + call_id, + output, + metadata, + } => { let output = output.into_function_call_output_payload(); Self::FunctionCallOutput { call_id, output, - metadata: None, + metadata, } } ResponseInputItem::CustomToolCallOutput { call_id, name, output, + metadata, } => Self::CustomToolCallOutput { call_id, name, output, - metadata: None, + metadata, }, ResponseInputItem::ToolSearchOutput { call_id, status, execution, tools, + metadata, } => Self::ToolSearchOutput { call_id: Some(call_id), status, execution, tools, - metadata: None, + metadata, }, } } @@ -2661,6 +2683,7 @@ mod tests { let item = ResponseInputItem::FunctionCallOutput { call_id: "call1".into(), output: FunctionCallOutputPayload::from_text("ok".into()), + metadata: None, }; let json = serde_json::to_string(&item)?; @@ -2679,6 +2702,7 @@ mod tests { body: FunctionCallOutputBody::Text("bad".into()), success: Some(false), }, + metadata: None, }; let json = serde_json::to_string(&item)?; @@ -2722,6 +2746,7 @@ mod tests { let item = ResponseInputItem::FunctionCallOutput { call_id: "call1".into(), output: payload, + metadata: None, }; let json = serde_json::to_string(&item)?; @@ -2744,6 +2769,7 @@ mod tests { detail: None, }, ]), + metadata: None, }; let json = serde_json::to_string(&item)?; @@ -2755,6 +2781,117 @@ mod tests { Ok(()) } + #[test] + fn function_call_output_roundtrips_metadata() -> Result<()> { + let input = ResponseInputItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_text("ok".to_string()), + metadata: Some(ResponseItemMetadata { + uuid: Some("uuid-1".to_string()), + session_source: Some(SessionSourceMetadata::Exec), + ..ResponseItemMetadata::default() + }), + }; + + assert_eq!( + ResponseItem::from(input.clone()), + ResponseItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_text("ok".to_string()), + metadata: Some(ResponseItemMetadata { + uuid: Some("uuid-1".to_string()), + session_source: Some(SessionSourceMetadata::Exec), + ..ResponseItemMetadata::default() + }), + } + ); + + assert_eq!( + serde_json::to_value(input)?, + serde_json::json!({ + "type": "function_call_output", + "call_id": "call-1", + "output": "ok", + "metadata": { + "uuid": "uuid-1", + "session_source": "exec" + }, + }) + ); + + Ok(()) + } + + #[test] + fn mcp_tool_call_output_roundtrips_metadata() -> Result<()> { + let input = ResponseInputItem::McpToolCallOutput { + call_id: "call-1".to_string(), + output: CallToolResult { + content: vec![serde_json::json!({"type":"text","text":"ok"})], + structured_content: None, + is_error: Some(false), + meta: None, + }, + metadata: Some(ResponseItemMetadata { + uuid: Some("uuid-1".to_string()), + sandbox_policy: Some(SandboxPolicyMetadata::ReadOnly), + ..ResponseItemMetadata::default() + }), + }; + + let expected_output = match &input { + ResponseInputItem::McpToolCallOutput { output, .. } => { + output.as_function_call_output_payload() + } + other => panic!("unexpected input variant: {other:?}"), + }; + + assert_eq!( + ResponseItem::from(input.clone()), + ResponseItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: expected_output, + metadata: Some(ResponseItemMetadata { + uuid: Some("uuid-1".to_string()), + sandbox_policy: Some(SandboxPolicyMetadata::ReadOnly), + ..ResponseItemMetadata::default() + }), + } + ); + + Ok(()) + } + + #[test] + fn custom_tool_call_output_roundtrips_metadata() -> Result<()> { + let input = ResponseInputItem::CustomToolCallOutput { + call_id: "call-1".to_string(), + name: Some("custom_tool".to_string()), + output: FunctionCallOutputPayload::from_text("done".to_string()), + metadata: Some(ResponseItemMetadata { + uuid: Some("uuid-1".to_string()), + review_decision: Some(ReviewDecisionMetadata::Approved), + ..ResponseItemMetadata::default() + }), + }; + + assert_eq!( + ResponseItem::from(input.clone()), + ResponseItem::CustomToolCallOutput { + call_id: "call-1".to_string(), + name: Some("custom_tool".to_string()), + output: FunctionCallOutputPayload::from_text("done".to_string()), + metadata: Some(ResponseItemMetadata { + uuid: Some("uuid-1".to_string()), + review_decision: Some(ReviewDecisionMetadata::Approved), + ..ResponseItemMetadata::default() + }), + } + ); + + Ok(()) + } + #[test] fn preserves_existing_image_data_urls() -> Result<()> { let call_tool_result = CallToolResult { @@ -3037,6 +3174,12 @@ mod tests { "additionalProperties": false, } })], + metadata: Some(ResponseItemMetadata { + uuid: Some("uuid-1".to_string()), + sandbox_policy: Some(SandboxPolicyMetadata::ReadOnly), + session_source: Some(SessionSourceMetadata::Cli), + ..ResponseItemMetadata::default() + }), }; assert_eq!( ResponseItem::from(input.clone()), @@ -3058,7 +3201,12 @@ mod tests { "additionalProperties": false, } })], - metadata: None, + metadata: Some(ResponseItemMetadata { + uuid: Some("uuid-1".to_string()), + sandbox_policy: Some(SandboxPolicyMetadata::ReadOnly), + session_source: Some(SessionSourceMetadata::Cli), + ..ResponseItemMetadata::default() + }), } ); @@ -3083,6 +3231,12 @@ mod tests { "additionalProperties": false, } }] + , + "metadata": { + "uuid": "uuid-1", + "sandbox_policy": "read_only", + "session_source": "cli" + } }) );