diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index d78c448b29..daa055f279 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -391,6 +391,7 @@ async fn spawn_agent_can_fork_parent_thread_history() { namespace: None, arguments: "{}".to_string(), call_id: parent_spawn_call_id.clone(), + metadata: None, }; parent_thread .codex @@ -474,6 +475,7 @@ async fn spawn_agent_fork_injects_output_for_parent_spawn_call() { namespace: None, arguments: "{}".to_string(), call_id: parent_spawn_call_id.clone(), + metadata: None, }; parent_thread .codex @@ -550,6 +552,7 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { namespace: None, arguments: "{}".to_string(), call_id: parent_spawn_call_id.clone(), + metadata: None, }; parent_thread .codex diff --git a/codex-rs/core/src/arc_monitor_tests.rs b/codex-rs/core/src/arc_monitor_tests.rs index cd0cb80b14..0a6fd3caa2 100644 --- a/codex-rs/core/src/arc_monitor_tests.rs +++ b/codex-rs/core/src/arc_monitor_tests.rs @@ -133,6 +133,7 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() namespace: None, arguments: "{\"old\":true}".to_string(), call_id: "call_old".to_string(), + metadata: None, }], &turn_context, ) @@ -161,6 +162,7 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() env: None, user: None, }), + metadata: None, }], &turn_context, ) diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index e88e1af124..3f863c8afb 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -79,6 +79,7 @@ fn reserialize_shell_outputs(items: &mut [ResponseItem]) { call_id, name, input: _, + .. } => { if name == "apply_patch" { shell_call_ids.insert(call_id.clone()); diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 769defabbc..1b94c9119f 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -147,6 +147,7 @@ fn reserializes_shell_outputs_for_function_and_custom_tool_calls() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "call-1".to_string(), @@ -158,6 +159,7 @@ fn reserializes_shell_outputs_for_function_and_custom_tool_calls() { call_id: "call-2".to_string(), name: "apply_patch".to_string(), input: "*** Begin Patch".to_string(), + metadata: None, }, ResponseItem::CustomToolCallOutput { call_id: "call-2".to_string(), @@ -176,6 +178,7 @@ fn reserializes_shell_outputs_for_function_and_custom_tool_calls() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "call-1".to_string(), @@ -187,6 +190,7 @@ fn reserializes_shell_outputs_for_function_and_custom_tool_calls() { call_id: "call-2".to_string(), name: "apply_patch".to_string(), input: "*** Begin Patch".to_string(), + metadata: None, }, ResponseItem::CustomToolCallOutput { call_id: "call-2".to_string(), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8a8a71049d..0e01a1ee3b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -321,6 +321,8 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_protocol::models::ResponseItemMetadata; +use codex_protocol::models::ReviewDecisionMetadata; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::InitialHistory; @@ -1010,6 +1012,92 @@ fn stamp_user_message_type_on_input_item(item: &mut ResponseInputItem, kind: Use *metadata = Some(metadata_value); } +fn review_decision_to_metadata(decision: &ReviewDecision) -> ReviewDecisionMetadata { + match decision { + ReviewDecision::Approved => ReviewDecisionMetadata::Approved, + ReviewDecision::ApprovedExecpolicyAmendment { .. } => { + ReviewDecisionMetadata::ApprovedWithAmendment + } + ReviewDecision::ApprovedForSession => ReviewDecisionMetadata::ApprovedForSession, + ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment, + } => match network_policy_amendment.action { + codex_protocol::protocol::NetworkPolicyRuleAction::Allow => { + ReviewDecisionMetadata::ApprovedWithNetworkPolicyAllow + } + codex_protocol::protocol::NetworkPolicyRuleAction::Deny => { + ReviewDecisionMetadata::DeniedWithNetworkPolicyDeny + } + }, + ReviewDecision::Denied => ReviewDecisionMetadata::Denied, + ReviewDecision::Abort => ReviewDecisionMetadata::Abort, + } +} + +fn response_item_tool_call_id(item: &ResponseItem) -> Option<&str> { + match item { + ResponseItem::LocalShellCall { + call_id: Some(call_id), + .. + } => Some(call_id), + ResponseItem::FunctionCall { call_id, .. } => Some(call_id), + ResponseItem::CustomToolCall { call_id, .. } => Some(call_id), + _ => None, + } +} + +fn stamp_tool_metadata_on_response_item( + item: ResponseItem, + metadata: ResponseItemMetadata, +) -> ResponseItem { + match item { + ResponseItem::LocalShellCall { + id, + call_id, + status, + action, + .. + } => ResponseItem::LocalShellCall { + id, + call_id, + status, + action, + metadata: Some(metadata), + }, + ResponseItem::FunctionCall { + id, + name, + namespace, + arguments, + call_id, + .. + } => ResponseItem::FunctionCall { + id, + name, + namespace, + arguments, + call_id, + metadata: Some(metadata), + }, + ResponseItem::CustomToolCall { + id, + status, + call_id, + name, + input, + .. + } => ResponseItem::CustomToolCall { + id, + status, + call_id, + name, + input, + metadata: Some(metadata), + }, + other => other, + } +} + #[derive(Clone)] pub(crate) struct SessionConfiguration { /// Provider identifier ("openai", "openrouter", ...). @@ -2904,6 +2992,10 @@ impl Session { match active.as_mut() { Some(at) => { let mut ts = at.turn_state.lock().await; + ts.insert_pending_approval_call_id( + effective_approval_id.clone(), + call_id.clone(), + ); ts.insert_pending_approval(effective_approval_id.clone(), tx_approve) } None => None, @@ -2969,6 +3061,7 @@ impl Session { match active.as_mut() { Some(at) => { let mut ts = at.turn_state.lock().await; + ts.insert_pending_approval_call_id(approval_id.clone(), call_id.clone()); ts.insert_pending_approval(approval_id.clone(), tx_approve) } None => None, @@ -3254,7 +3347,20 @@ impl Session { } } + pub async fn record_approval_outcome(&self, approval_id: &str, decision: &ReviewDecision) { + let mut active = self.active_turn.lock().await; + let Some(at) = active.as_mut() else { + return; + }; + let mut ts = at.turn_state.lock().await; + let call_id = ts + .remove_pending_approval_call_id(approval_id) + .unwrap_or_else(|| approval_id.to_string()); + ts.record_approval_outcome(call_id, review_decision_to_metadata(decision)); + } + pub async fn notify_approval(&self, approval_id: &str, decision: ReviewDecision) { + self.record_approval_outcome(approval_id, &decision).await; let entry = { let mut active = self.active_turn.lock().await; match active.as_mut() { @@ -3275,6 +3381,53 @@ impl Session { } } + async fn stamp_tool_approval_metadata( + &self, + _turn_context: &TurnContext, + response_item: ResponseItem, + ) -> ResponseItem { + if !self.enabled(Feature::ItemMetadata) { + return response_item; + } + let Some(call_id) = response_item_tool_call_id(&response_item) else { + return response_item; + }; + + let (outcome, has_pending_approval) = { + let active = self.active_turn.lock().await; + let Some(at) = active.as_ref() else { + return response_item; + }; + let ts = at.turn_state.lock().await; + let outcome = ts.approval_outcome_for_call_id(call_id); + let has_pending_approval = ts.has_pending_approval_for_call_id(call_id); + (outcome, has_pending_approval) + }; + + let mut metadata = match &response_item { + ResponseItem::LocalShellCall { metadata, .. } + | ResponseItem::FunctionCall { metadata, .. } + | ResponseItem::CustomToolCall { metadata, .. } => metadata.clone().unwrap_or_default(), + _ => return response_item, + }; + + match outcome { + Some(review_decision) => { + metadata.is_tool_call_escalated = Some(true); + metadata.review_decision = Some(review_decision); + } + None if !has_pending_approval => { + metadata.is_tool_call_escalated = Some(false); + metadata.review_decision = None; + } + None => { + return response_item; + } + } + + stamp_tool_metadata_on_response_item(response_item, metadata) + } + pub async fn resolve_elicitation( &self, server_name: String, @@ -3769,6 +3922,10 @@ impl Session { turn_context: &TurnContext, response_item: ResponseItem, ) { + let response_item = self + .stamp_tool_approval_metadata(turn_context, response_item) + .await; + // Add to conversation history and persist response item to rollout. self.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) .await; @@ -4731,6 +4888,8 @@ mod handlers { } match decision { ReviewDecision::Abort => { + sess.record_approval_outcome(&approval_id, &ReviewDecision::Abort) + .await; sess.interrupt_task().await; } other => sess.notify_approval(&approval_id, other).await, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 8201424d8e..e0ad0d4a3c 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -77,6 +77,7 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { call_id: "call-1".to_string(), name: "tool".to_string(), input: "{}".to_string(), + metadata: None, }, }), }) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a3c33baeb4..c3acaf981c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -3920,6 +3920,137 @@ async fn task_finish_emits_prompt_queued_metadata_for_injected_user_input_when_f )); } +#[test] +fn review_decision_metadata_mapping_is_stable() { + assert_eq!( + review_decision_to_metadata(&ReviewDecision::Approved), + codex_protocol::models::ReviewDecisionMetadata::Approved + ); + assert_eq!( + review_decision_to_metadata(&ReviewDecision::Denied), + codex_protocol::models::ReviewDecisionMetadata::Denied + ); + assert_eq!( + review_decision_to_metadata(&ReviewDecision::Abort), + codex_protocol::models::ReviewDecisionMetadata::Abort + ); + assert_eq!( + review_decision_to_metadata(&ReviewDecision::ApprovedForSession), + codex_protocol::models::ReviewDecisionMetadata::ApprovedForSession + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_call_metadata_stamps_escalated_review_decision_when_feature_enabled() { + let (mut sess, tc, rx) = make_session_and_context_with_rx().await; + Arc::get_mut(&mut sess) + .expect("session should be uniquely owned in this test") + .features + .enable(crate::features::Feature::ItemMetadata) + .expect("feature flag should be enabled for this test"); + + sess.spawn_task( + Arc::clone(&tc), + vec![UserInput::Text { + text: "start".to_string(), + text_elements: Vec::new(), + }], + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + while rx.try_recv().is_ok() {} + + sess.record_approval_outcome("call-1", &ReviewDecision::Denied) + .await; + sess.record_response_item_and_emit_turn_item( + tc.as_ref(), + ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-1".to_string(), + metadata: None, + }, + ) + .await; + + let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected raw response item event") + .expect("channel open"); + assert!(matches!( + event.msg, + EventMsg::RawResponseItem(ref ev) + if matches!( + &ev.item, + ResponseItem::FunctionCall { + metadata: Some(metadata), + .. + } if metadata.is_tool_call_escalated == Some(true) + && metadata.review_decision + == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) + ) + )); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_call_metadata_stamps_non_escalated_false_when_feature_enabled() { + let (mut sess, tc, rx) = make_session_and_context_with_rx().await; + Arc::get_mut(&mut sess) + .expect("session should be uniquely owned in this test") + .features + .enable(crate::features::Feature::ItemMetadata) + .expect("feature flag should be enabled for this test"); + + sess.spawn_task( + Arc::clone(&tc), + vec![UserInput::Text { + text: "start".to_string(), + text_elements: Vec::new(), + }], + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + while rx.try_recv().is_ok() {} + + sess.record_response_item_and_emit_turn_item( + tc.as_ref(), + ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-2".to_string(), + metadata: None, + }, + ) + .await; + + let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected raw response item event") + .expect("channel open"); + assert!(matches!( + event.msg, + EventMsg::RawResponseItem(ref ev) + if matches!( + &ev.item, + ResponseItem::FunctionCall { + metadata: Some(metadata), + .. + } if metadata.is_tool_call_escalated == Some(false) + && metadata.review_decision.is_none() + ) + )); +} + #[tokio::test] async fn steer_input_requires_active_turn() { let (sess, _tc, _rx) = make_session_and_context_with_rx().await; @@ -4119,6 +4250,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { call_id: "call-1".to_string(), name: "shell".to_string(), input: "{}".to_string(), + metadata: None, }; let call = ToolRouter::build_tool_call(session.as_ref(), item.clone()) diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 1e49a7bd20..b7e1452ee3 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -281,6 +281,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "call-1".to_string(), @@ -300,6 +301,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { call_id: "tool-1".to_string(), name: "js_repl".to_string(), input: "view_image".to_string(), + metadata: None, }, ResponseItem::CustomToolCallOutput { call_id: "tool-1".to_string(), @@ -344,6 +346,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "call-1".to_string(), @@ -363,6 +366,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { call_id: "tool-1".to_string(), name: "js_repl".to_string(), input: "view_image".to_string(), + metadata: None, }, ResponseItem::CustomToolCallOutput { call_id: "tool-1".to_string(), @@ -536,6 +540,7 @@ fn remove_first_item_removes_matching_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "call-1".to_string(), @@ -560,6 +565,7 @@ fn remove_first_item_removes_matching_call_for_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-2".to_string(), + metadata: None, }, ]; let mut h = create_history_with_items(items); @@ -577,6 +583,7 @@ fn remove_last_item_removes_matching_call_for_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-delete-last".to_string(), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "call-delete-last".to_string(), @@ -661,6 +668,7 @@ fn remove_first_item_handles_local_shell_pair() { env: None, user: None, }), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "call-3".to_string(), @@ -817,6 +825,7 @@ fn remove_first_item_handles_custom_tool_pair() { call_id: "tool-1".to_string(), name: "my_tool".to_string(), input: "{}".to_string(), + metadata: None, }, ResponseItem::CustomToolCallOutput { call_id: "tool-1".to_string(), @@ -842,6 +851,7 @@ fn normalization_retains_local_shell_outputs() { env: None, user: None, }), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "shell-1".to_string(), @@ -1052,6 +1062,7 @@ fn normalize_adds_missing_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), + metadata: None, }]; let mut h = create_history_with_items(items); @@ -1066,6 +1077,7 @@ fn normalize_adds_missing_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "call-x".to_string(), @@ -1084,6 +1096,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() { call_id: "tool-x".to_string(), name: "custom".to_string(), input: "{}".to_string(), + metadata: None, }]; let mut h = create_history_with_items(items); @@ -1098,6 +1111,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() { call_id: "tool-x".to_string(), name: "custom".to_string(), input: "{}".to_string(), + metadata: None, }, ResponseItem::CustomToolCallOutput { call_id: "tool-x".to_string(), @@ -1121,6 +1135,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { env: None, user: None, }), + metadata: None, }]; let mut h = create_history_with_items(items); @@ -1140,6 +1155,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { env: None, user: None, }), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "shell-1".to_string(), @@ -1188,6 +1204,7 @@ fn normalize_mixed_inserts_and_removals() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), + metadata: None, }, // Orphan output that should be removed ResponseItem::FunctionCallOutput { @@ -1201,6 +1218,7 @@ fn normalize_mixed_inserts_and_removals() { call_id: "t1".to_string(), name: "tool".to_string(), input: "{}".to_string(), + metadata: None, }, // Local shell call also gets an inserted function call output ResponseItem::LocalShellCall { @@ -1214,6 +1232,7 @@ fn normalize_mixed_inserts_and_removals() { env: None, user: None, }), + metadata: None, }, ]; let mut h = create_history_with_items(items); @@ -1229,6 +1248,7 @@ fn normalize_mixed_inserts_and_removals() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "c1".to_string(), @@ -1240,6 +1260,7 @@ fn normalize_mixed_inserts_and_removals() { call_id: "t1".to_string(), name: "tool".to_string(), input: "{}".to_string(), + metadata: None, }, ResponseItem::CustomToolCallOutput { call_id: "t1".to_string(), @@ -1273,6 +1294,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), + metadata: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1285,6 +1307,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "call-x".to_string(), @@ -1337,6 +1360,7 @@ fn normalize_adds_missing_output_for_custom_tool_call_panics_in_debug() { call_id: "tool-x".to_string(), name: "custom".to_string(), input: "{}".to_string(), + metadata: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1357,6 +1381,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id_panics_in_debug() env: None, user: None, }), + metadata: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1450,6 +1475,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "c2".to_string(), @@ -1461,6 +1487,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { call_id: "t1".to_string(), name: "tool".to_string(), input: "{}".to_string(), + metadata: None, }, ResponseItem::LocalShellCall { id: None, @@ -1473,6 +1500,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { env: None, user: None, }), + metadata: None, }, ]; let mut h = create_history_with_items(items); diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 310e64a779..48e19c0df9 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -45,10 +45,6 @@ const ALIASES: &[Alias] = &[ legacy_key: "memory_tool", feature: Feature::MemoryTool, }, - Alias { - legacy_key: "user_message_type_metadata", - feature: Feature::ItemMetadata, - }, ]; pub(crate) fn legacy_feature_keys() -> impl Iterator { diff --git a/codex-rs/core/src/features_tests.rs b/codex-rs/core/src/features_tests.rs index 46c0998ed3..336e0c142f 100644 --- a/codex-rs/core/src/features_tests.rs +++ b/codex-rs/core/src/features_tests.rs @@ -142,12 +142,6 @@ fn collab_is_legacy_alias_for_multi_agent() { assert_eq!(feature_for_key("collab"), Some(Feature::Collab)); } -#[test] -fn user_message_type_metadata_is_legacy_alias_for_item_metadata() { - assert_eq!(feature_for_key("user_message_type_metadata"), Some(Feature::ItemMetadata)); - assert_eq!(feature_for_key("item_metadata"), Some(Feature::ItemMetadata)); -} - #[test] fn item_metadata_is_under_development_and_disabled_by_default() { assert_eq!(Feature::ItemMetadata.stage(), Stage::UnderDevelopment); diff --git a/codex-rs/core/src/guardian_tests.rs b/codex-rs/core/src/guardian_tests.rs index 8484e10e46..ebfe3cb73e 100644 --- a/codex-rs/core/src/guardian_tests.rs +++ b/codex-rs/core/src/guardian_tests.rs @@ -115,6 +115,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { namespace: None, arguments: "{\"path\":\"README.md\"}".to_string(), call_id: "call-1".to_string(), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "call-1".to_string(), @@ -444,6 +445,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() namespace: None, arguments: "{\"repo\":\"openai/codex\"}".to_string(), call_id: "call-1".to_string(), + metadata: None, }, ResponseItem::FunctionCallOutput { call_id: "call-1".to_string(), diff --git a/codex-rs/core/src/rollout/truncation_tests.rs b/codex-rs/core/src/rollout/truncation_tests.rs index 2550fd50c4..6f3ca2fdac 100644 --- a/codex-rs/core/src/rollout/truncation_tests.rs +++ b/codex-rs/core/src/rollout/truncation_tests.rs @@ -54,6 +54,7 @@ fn truncates_rollout_from_start_before_nth_user_only() { name: "tool".to_string(), namespace: None, arguments: "{}".to_string(), + metadata: None, }, assistant_msg("a4"), ]; diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 50961580d2..90a7a48231 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -10,6 +10,7 @@ use tokio_util::task::AbortOnDropHandle; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ReviewDecisionMetadata; use codex_protocol::models::UserMessageType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; @@ -83,6 +84,8 @@ impl ActiveTurn { #[derive(Default)] pub(crate) struct TurnState { pending_approvals: HashMap>, + pending_approval_call_ids: HashMap, + approval_outcomes_by_call_id: HashMap, pending_request_permissions: HashMap>, pending_user_input: HashMap>, pending_elicitations: HashMap<(String, RequestId), oneshot::Sender>, @@ -102,6 +105,39 @@ impl TurnState { self.pending_approvals.insert(key, tx) } + pub(crate) fn insert_pending_approval_call_id( + &mut self, + approval_key: String, + call_id: String, + ) -> Option { + self.pending_approval_call_ids.insert(approval_key, call_id) + } + + pub(crate) fn remove_pending_approval_call_id(&mut self, approval_key: &str) -> Option { + self.pending_approval_call_ids.remove(approval_key) + } + + pub(crate) fn record_approval_outcome( + &mut self, + call_id: String, + decision: ReviewDecisionMetadata, + ) { + self.approval_outcomes_by_call_id.insert(call_id, decision); + } + + pub(crate) fn approval_outcome_for_call_id( + &self, + call_id: &str, + ) -> Option { + self.approval_outcomes_by_call_id.get(call_id).cloned() + } + + pub(crate) fn has_pending_approval_for_call_id(&self, call_id: &str) -> bool { + self.pending_approval_call_ids + .values() + .any(|pending_call_id| pending_call_id == call_id) + } + pub(crate) fn remove_pending_approval( &mut self, key: &str, @@ -111,6 +147,8 @@ impl TurnState { pub(crate) fn clear_pending(&mut self) { self.pending_approvals.clear(); + self.pending_approval_call_ids.clear(); + self.approval_outcomes_by_call_id.clear(); self.pending_request_permissions.clear(); self.pending_user_input.clear(); self.pending_elicitations.clear(); diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index de11913d7c..f86dba0613 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -56,6 +56,7 @@ fn drops_from_last_user_only() { name: "tool".to_string(), namespace: None, arguments: "{}".to_string(), + metadata: None, }, assistant_msg("a4"), ]; diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 6350323d1b..270f6161bf 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -139,6 +139,7 @@ async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<() namespace: Some("mcp__codex_apps__calendar".to_string()), arguments: "{}".to_string(), call_id: "call-namespace".to_string(), + metadata: None, }, ) .await? diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index 513a85bd50..44375ccbde 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -83,6 +83,7 @@ fn response_item_records_turn_ttft_for_first_output_signals() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), + metadata: None, } )); assert!(response_item_records_turn_ttft( @@ -92,6 +93,7 @@ fn response_item_records_turn_ttft_for_first_output_signals() { call_id: "call-2".to_string(), name: "custom".to_string(), input: "echo hi".to_string(), + metadata: None, } )); assert!(response_item_records_turn_ttft(&ResponseItem::Message { diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c82c72d14b..2e011f7e29 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -366,6 +366,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { call_id: "legacy-js-call".to_string(), name: "js_repl".to_string(), input: "console.log('legacy image flow')".to_string(), + metadata: None, }; let legacy_image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; let rollout = vec![ @@ -522,6 +523,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { namespace: None, arguments: "{\"path\":\"/tmp/example.webp\"}".to_string(), call_id: function_call_id.to_string(), + metadata: None, }), }, RolloutLine { @@ -544,6 +546,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { call_id: custom_call_id.to_string(), name: "js_repl".to_string(), input: "console.log('image flow')".to_string(), + metadata: None, }), }, RolloutLine { @@ -1887,6 +1890,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { namespace: None, arguments: "{}".into(), call_id: "function-call-id".into(), + metadata: None, }); prompt.input.push(ResponseItem::FunctionCallOutput { call_id: "function-call-id".into(), @@ -1903,6 +1907,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { env: None, user: None, }), + metadata: None, }); prompt.input.push(ResponseItem::CustomToolCall { id: Some("custom-tool-id".into()), @@ -1910,6 +1915,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { call_id: "custom-tool-call-id".into(), name: "custom_tool".into(), input: "{}".into(), + metadata: None, }); prompt.input.push(ResponseItem::CustomToolCallOutput { call_id: "custom-tool-call-id".into(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 38da6d541f..ad955867d2 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -262,11 +262,30 @@ pub enum UserMessageType { PromptQueued, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum ReviewDecisionMetadata { + Approved, + Denied, + Abort, + ApprovedForSession, + ApprovedWithAmendment, + ApprovedWithNetworkPolicyAllow, + DeniedWithNetworkPolicyDeny, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] pub struct ResponseItemMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub user_message_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub is_tool_call_escalated: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub review_decision: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] @@ -344,6 +363,9 @@ pub enum ResponseItem { call_id: Option, status: LocalShellStatus, action: LocalShellAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + metadata: Option, }, FunctionCall { #[serde(default, skip_serializing)] @@ -358,6 +380,9 @@ pub enum ResponseItem { // Session::handle_function_call parse it into a Value. arguments: String, call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + metadata: Option, }, ToolSearchCall { #[serde(default, skip_serializing)] @@ -391,6 +416,9 @@ pub enum ResponseItem { call_id: String, name: String, input: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + metadata: Option, }, // `custom_tool_call_output.output` uses the same wire encoding as // `function_call_output.output` so freeform tools can return either plain