diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index ec09ed63e4..964bf99875 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -869,6 +869,8 @@ fn plugin_measurements(rows: Vec) -> PluginMeasurementsInp turn_id: "turn-1".to_string(), item_id: "item-1".to_string(), originator: "codex_cli_rs".to_string(), + model_slug: None, + reasoning_effort: None, plugin_id: "sample@openai-curated".to_string(), execution_id: "execution-1".to_string(), operation: "security_scan".to_string(), @@ -946,6 +948,7 @@ fn sample_command_execution_item_with_id( duration_ms: Option, ) -> ThreadItem { ThreadItem::CommandExecution { + model_context: None, id: id.to_string(), plugin_id: None, script_path: None, @@ -1790,6 +1793,8 @@ fn command_execution_event_serializes_expected_shape() { let event = TrackEventRequest::CommandExecution(CodexCommandExecutionEventRequest { event_type: "codex_command_execution_event", event_params: CodexCommandExecutionEventParams { + model_slug: None, + reasoning_effort: None, base: CodexToolItemEventBase { thread_id: "thread-1".to_string(), session_id: "session-thread-1".to_string(), @@ -1848,6 +1853,8 @@ fn command_execution_event_serializes_expected_shape() { json!({ "event_type": "codex_command_execution_event", "event_params": { + "model_slug": null, + "reasoning_effort": null, "thread_id": "thread-1", "session_id": "session-thread-1", "turn_id": "turn-1", @@ -2781,23 +2788,35 @@ async fn item_lifecycle_notifications_publish_command_execution_event() { &mut events, ) .await; - reducer - .ingest( - AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( - ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 1_000, - item: sample_command_execution_item( - CommandExecutionStatus::InProgress, - /*exit_code*/ None, - /*duration_ms*/ None, - ), - }, - ))), - &mut events, - ) - .await; + for model in ["invoking-model", "later-model"] { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( + ItemStartedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + started_at_ms: 1_000, + item: { + let mut item = sample_command_execution_item( + CommandExecutionStatus::InProgress, + /*exit_code*/ None, + /*duration_ms*/ None, + ); + if let ThreadItem::CommandExecution { model_context, .. } = &mut item { + *model_context = + Some(codex_protocol::items::ModelInvocationContext { + model_slug: model.to_string(), + reasoning_effort: Some("max".to_string()), + }); + } + item + }, + }, + ))), + &mut events, + ) + .await; + } assert!( events.is_empty(), "tool item event should emit on completion" @@ -2844,6 +2863,8 @@ async fn item_lifecycle_notifications_publish_command_execution_event() { let payload = serde_json::to_value(&events).expect("serialize events"); assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_params"]["model_slug"], "invoking-model"); + assert_eq!(payload[0]["event_params"]["reasoning_effort"], "max"); assert_eq!(payload[0]["event_type"], "codex_command_execution_event"); assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); assert_eq!(payload[0]["event_params"]["session_id"], "session-thread-1"); @@ -2899,7 +2920,7 @@ async fn plugin_measurement_batch_emits_directly_and_filters_invalid_rows() { for index in 0..9 { too_many_dimensions.insert(format!("dimension_{index}"), "allowed".to_string()); } - let measurements = plugin_measurements(vec![ + let mut measurements = plugin_measurements(vec![ PluginMeasurementRow { measurement_name: "finding_count".to_string(), number_value: 3.0, @@ -2921,6 +2942,8 @@ async fn plugin_measurement_batch_emits_directly_and_filters_invalid_rows() { dimensions: BTreeMap::new(), }, ]); + measurements.model_slug = Some("invoking-model".to_string()); + measurements.reasoning_effort = Some("max".to_string()); reducer .ingest( AnalyticsFact::Custom(CustomAnalyticsFact::PluginMeasurements(measurements)), @@ -2942,6 +2965,8 @@ async fn plugin_measurement_batch_emits_directly_and_filters_invalid_rows() { "operation": "security_scan", "measurement_name": "finding_count", "originator": "codex_cli_rs", + "model_slug": "invoking-model", + "reasoning_effort": "max", "number_value": 3.0, "dimensions": {"severity": "high"}, }, @@ -2957,6 +2982,8 @@ async fn plugin_measurement_batch_emits_directly_and_filters_invalid_rows() { "operation": "security_scan", "measurement_name": "files_scanned", "originator": "codex_cli_rs", + "model_slug": "invoking-model", + "reasoning_effort": "max", "number_value": 17.0, "dimensions": null, }, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index ccb5a6ff4e..4f4146b35c 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -420,6 +420,8 @@ async fn api_key_auth_sends_only_plugin_events_to_codex_backend() { TrackEventRequest::PluginMeasurement(CodexPluginMeasurementEventRequest { event_type: "codex_plugin_measurement_event", event_params: CodexPluginMeasurementEventParams { + model_slug: None, + reasoning_effort: None, thread_id: thread_id.to_string(), turn_id: "turn-1".to_string(), item_id: "item-1".to_string(), @@ -712,6 +714,8 @@ fn track_plugin_measurements_rejects_unbounded_inputs_before_queueing() { turn_id: "turn-1".to_string(), item_id: "item-1".to_string(), originator: "codex_cli_rs".to_string(), + model_slug: None, + reasoning_effort: None, plugin_id: "sample@openai-curated".to_string(), execution_id: "execution-1".to_string(), operation: "security_scan".to_string(), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 61303b0e46..d40651be98 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -778,6 +778,8 @@ pub(crate) enum WebSearchActionKind { #[derive(Serialize)] pub(crate) struct CodexCommandExecutionEventParams { + pub(crate) model_slug: Option, + pub(crate) reasoning_effort: Option, #[serde(flatten)] pub(crate) base: CodexToolItemEventBase, pub(crate) plugin_id: Option, @@ -803,6 +805,8 @@ pub(crate) struct CodexPluginMeasurementEventParams { pub(crate) turn_id: String, pub(crate) item_id: String, pub(crate) originator: String, + pub(crate) model_slug: Option, + pub(crate) reasoning_effort: Option, pub(crate) plugin_id: String, pub(crate) execution_id: String, pub(crate) operation: String, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 697393a733..73de212d8a 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -604,6 +604,8 @@ pub struct PluginMeasurementsInput { pub turn_id: String, pub item_id: String, pub originator: String, + pub model_slug: Option, + pub reasoning_effort: Option, pub plugin_id: String, pub execution_id: String, pub operation: String, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f2e3c6454d..a5e0892c6e 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -159,6 +159,7 @@ use codex_login::default_client::originator; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::items::ModelInvocationContext; use codex_protocol::items::is_safe_plugin_relative_path; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SessionSource; @@ -203,7 +204,7 @@ pub(crate) struct AnalyticsReducer { turns: HashMap, connections: HashMap, threads: HashMap, - tool_items_started_at_ms: HashMap, + tool_items_started_at_ms: HashMap)>, tool_response_states: HashMap<(String, String), ToolResponseState>, code_mode_cells: HashMap>, pending_reviews: HashMap, @@ -1891,14 +1892,18 @@ impl AnalyticsReducer { else { return; }; - self.tool_items_started_at_ms.insert( - ToolItemKey { + let model_context = match ¬ification.item { + ThreadItem::CommandExecution { model_context, .. } => model_context.clone(), + _ => None, + }; + self.tool_items_started_at_ms + .entry(ToolItemKey { thread_id: notification.thread_id, turn_id: notification.turn_id, item_id: item_id.to_string(), - }, - started_at_ms, - ); + }) + .and_modify(|(timestamp, _)| *timestamp = started_at_ms) + .or_insert((started_at_ms, model_context)); } ServerNotification::ItemCompleted(notification) => { if matches!(notification.item, ThreadItem::SubAgentActivity { .. }) { @@ -1931,7 +1936,9 @@ impl AnalyticsReducer { turn_id: notification.turn_id.clone(), item_id: item_id.to_string(), }; - let Some(started_at_ms) = self.tool_items_started_at_ms.remove(&key) else { + let Some((started_at_ms, model_context)) = + self.tool_items_started_at_ms.remove(&key) + else { tracing::warn!( thread_id = %notification.thread_id, turn_id = %notification.turn_id, @@ -1953,6 +1960,7 @@ impl AnalyticsReducer { thread_id: ¬ification.thread_id, turn_id: ¬ification.turn_id, item: ¬ification.item, + model_context: model_context.as_ref(), started_at_ms, completed_at_ms, connection_state, @@ -2063,6 +2071,8 @@ impl AnalyticsReducer { turn_id, item_id, originator, + model_slug, + reasoning_effort, plugin_id, execution_id, operation, @@ -2079,6 +2089,8 @@ impl AnalyticsReducer { turn_id: turn_id.clone(), item_id: item_id.clone(), originator: originator.clone(), + model_slug: model_slug.clone(), + reasoning_effort: reasoning_effort.clone(), plugin_id: plugin_id.clone(), execution_id: execution_id.clone(), operation: operation.clone(), @@ -2598,6 +2610,7 @@ struct ToolItemEventInput<'a> { thread_id: &'a str, turn_id: &'a str, item: &'a ThreadItem, + model_context: Option<&'a ModelInvocationContext>, started_at_ms: u64, completed_at_ms: u64, connection_state: &'a ConnectionState, @@ -2611,6 +2624,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { thread_id, turn_id, item, + model_context, started_at_ms, completed_at_ms, connection_state, @@ -2655,6 +2669,9 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { CodexCommandExecutionEventRequest { event_type: "codex_command_execution_event", event_params: CodexCommandExecutionEventParams { + model_slug: model_context.map(|context| context.model_slug.clone()), + reasoning_effort: model_context + .and_then(|context| context.reasoning_effort.clone()), base, plugin_id: plugin_id.clone(), script_path: safe_plugin_relative_script_path( diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 06c4049111..a41283ffce 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -97,6 +97,7 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th let presentation = CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { + model_context: None, id: payload.call_id.clone(), plugin_id: payload.plugin_id.clone(), script_path: payload.script_path.clone(), @@ -123,6 +124,7 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { + model_context: None, id: payload.call_id.clone(), plugin_id: payload.plugin_id.clone(), script_path: payload.script_path.clone(), @@ -199,6 +201,7 @@ pub fn build_item_from_guardian_event( }]; Some(ThreadItem::CommandExecution { id: id.clone(), + model_context: assessment.model_context.clone(), plugin_id: assessment.plugin_id.clone(), script_path: assessment.script_path.clone(), command, @@ -237,6 +240,7 @@ pub fn build_item_from_guardian_event( }; Some(ThreadItem::CommandExecution { id: id.clone(), + model_context: assessment.model_context.clone(), plugin_id: assessment.plugin_id.clone(), script_path: assessment.script_path.clone(), command, diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs index 0a25728b81..3bb88ff493 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs @@ -131,6 +131,7 @@ fn guardian_stdin_reviews_preserve_parent_command_history() { let assessment = GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "review-stdin".into(), target_item_id: Some("terminal-command".into()), plugin_id: None, diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 44ebe0d62b..0c46d8c613 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2255,6 +2255,7 @@ mod tests { .to_string(), }]; let command_item = CoreTurnItem::CommandExecution(CoreCommandExecutionItem { + model_context: None, id: "exec-1".to_string(), plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), @@ -2340,6 +2341,7 @@ mod tests { assert_eq!( build_turns_from_rollout_items(&items[..2])[0].items, vec![ThreadItem::CommandExecution { + model_context: None, id: "exec-1".to_string(), plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), @@ -2365,6 +2367,7 @@ mod tests { assert_eq!( turns[0].items, vec![ThreadItem::CommandExecution { + model_context: None, id: "exec-1".to_string(), plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), @@ -3051,6 +3054,7 @@ mod tests { assert_eq!( turns[0].items[2], ThreadItem::CommandExecution { + model_context: None, id: "exec-1".into(), plugin_id: None, script_path: None, @@ -3321,6 +3325,7 @@ mod tests { assert_eq!( turns[0].items[1], ThreadItem::CommandExecution { + model_context: None, id: "exec-declined".into(), plugin_id: None, script_path: None, @@ -3372,6 +3377,7 @@ mod tests { }), EventMsg::GuardianAssessment(GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "review-guardian-exec".into(), target_item_id: Some("guardian-exec".into()), plugin_id: Some("sample@openai-curated".into()), @@ -3394,6 +3400,7 @@ mod tests { }), EventMsg::GuardianAssessment(GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "review-guardian-exec".into(), target_item_id: Some("guardian-exec".into()), plugin_id: Some("sample@openai-curated".into()), @@ -3428,6 +3435,7 @@ mod tests { assert_eq!( turns[0].items[1], ThreadItem::CommandExecution { + model_context: None, id: "guardian-exec".into(), plugin_id: Some("sample@openai-curated".into()), script_path: Some("scripts/run.py".into()), @@ -3467,6 +3475,7 @@ mod tests { }), EventMsg::GuardianAssessment(GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "review-guardian-execve".into(), target_item_id: Some("guardian-execve".into()), plugin_id: Some("sample@openai-curated".into()), @@ -3500,6 +3509,7 @@ mod tests { assert_eq!( turns[0].items[1], ThreadItem::CommandExecution { + model_context: None, id: "guardian-execve".into(), plugin_id: Some("sample@openai-curated".into()), script_path: Some("scripts/run.py".into()), @@ -3701,6 +3711,7 @@ mod tests { assert_eq!( turns[0].items[1], ThreadItem::CommandExecution { + model_context: None, id: "exec-late".into(), plugin_id: None, script_path: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index f56386cae6..de18c65fe7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -287,6 +287,10 @@ pub enum ThreadItem { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] CommandExecution { + #[serde(skip)] + #[schemars(skip)] + #[ts(skip)] + model_context: Option, id: String, /// Trusted first-party plugin id when this command resolves to one plugin script. #[serde(default)] @@ -910,6 +914,7 @@ impl From for ThreadItem { ); ThreadItem::CommandExecution { id: command.id, + model_context: command.model_context, plugin_id: command.plugin_id, script_path: command.script_path, command: presentation.command, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 62414af295..3ea4483dda 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3210,6 +3210,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { ); let command_item = TurnItem::CommandExecution(CommandExecutionItem { + model_context: None, id: "exec-1".to_string(), plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), @@ -3242,6 +3243,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { assert_eq!( ThreadItem::from(command_item), ThreadItem::CommandExecution { + model_context: None, id: "exec-1".to_string(), plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index c5077bf93f..e986d2e574 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -92,6 +92,7 @@ use codex_core::ThreadManager; use codex_features::Feature; use codex_protocol::ThreadId; use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool; +use codex_protocol::items::ModelInvocationContext; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::plan_tool::UpdatePlanArgs; @@ -129,6 +130,7 @@ enum CommandExecutionApprovalPresentation { #[derive(Debug, PartialEq)] struct CommandExecutionCompletionItem { + model_context: Option, plugin_id: Option, script_path: Option, command: String, @@ -294,6 +296,7 @@ pub(crate) async fn apply_bespoke_event_handling( ) { Some(ThreadItem::CommandExecution { id, + model_context, plugin_id, script_path, command, @@ -303,6 +306,7 @@ pub(crate) async fn apply_bespoke_event_handling( }) => Some(( id, CommandExecutionCompletionItem { + model_context, plugin_id, script_path, command, @@ -324,6 +328,7 @@ pub(crate) async fn apply_bespoke_event_handling( &conversation_id, assessment_turn_id.clone(), target_item_id.clone(), + completion_item.model_context.clone(), completion_item.plugin_id.clone(), completion_item.script_path.clone(), completion_item.command.clone(), @@ -669,6 +674,7 @@ pub(crate) async fn apply_bespoke_event_handling( .map(CommandExecutionApprovalDecision::from) .collect::>(); let ExecApprovalRequestEvent { + model_context, kind, call_id, plugin_id, @@ -723,6 +729,7 @@ pub(crate) async fn apply_bespoke_event_handling( CommandExecutionApprovalPresentation::Network(network_approval_context) } else { let completion_item = CommandExecutionCompletionItem { + model_context, plugin_id, script_path, command: command_presentation.command, @@ -751,6 +758,7 @@ pub(crate) async fn apply_bespoke_event_handling( &conversation_id, event_turn_id.clone(), call_id.clone(), + completion_item.model_context.clone(), completion_item.plugin_id.clone(), completion_item.script_path.clone(), completion_item.command.clone(), @@ -1389,6 +1397,7 @@ async fn start_command_execution_item( conversation_id: &ThreadId, turn_id: String, item_id: String, + model_context: Option, plugin_id: Option, script_path: Option, command: String, @@ -1412,6 +1421,7 @@ async fn start_command_execution_item( started_at_ms: now_unix_timestamp_ms(), item: ThreadItem::CommandExecution { id: item_id, + model_context, plugin_id, script_path, command, @@ -1456,6 +1466,7 @@ async fn complete_command_execution_item( let item = ThreadItem::CommandExecution { id: item_id, + model_context: completion_item.model_context, plugin_id: completion_item.plugin_id, script_path: completion_item.script_path, command: completion_item.command, @@ -2183,6 +2194,7 @@ mod tests { fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { + model_context: None, plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), command: command.to_string(), @@ -2217,6 +2229,7 @@ mod tests { }; GuardianAssessmentEvent { review_reason: None, + model_context: None, id: format!("review-{id}"), target_item_id: Some(id.to_string()), plugin_id: Some("sample@openai-curated".to_string()), @@ -2285,6 +2298,7 @@ mod tests { "turn-from-event", &GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "review-1".to_string(), target_item_id: Some("item-1".to_string()), plugin_id: None, @@ -2334,6 +2348,7 @@ mod tests { "turn-from-event", &GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "review-2".to_string(), target_item_id: Some("item-2".to_string()), plugin_id: None, @@ -2391,6 +2406,7 @@ mod tests { "turn-from-event", &GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "review-3".to_string(), target_item_id: None, plugin_id: None, @@ -2446,6 +2462,7 @@ mod tests { &conversation_id, "turn-1".to_string(), "cmd-1".to_string(), + completion_item.model_context.clone(), completion_item.plugin_id.clone(), completion_item.script_path.clone(), completion_item.command.clone(), @@ -2466,6 +2483,7 @@ mod tests { assert_eq!( payload.item, ThreadItem::CommandExecution { + model_context: None, id: "cmd-1".to_string(), plugin_id: completion_item.plugin_id.clone(), script_path: completion_item.script_path.clone(), @@ -2488,6 +2506,7 @@ mod tests { &conversation_id, "turn-1".to_string(), "cmd-1".to_string(), + completion_item.model_context.clone(), completion_item.plugin_id.clone(), completion_item.script_path.clone(), completion_item.command.clone(), @@ -2524,6 +2543,7 @@ mod tests { &conversation_id, "turn-1".to_string(), "cmd-1".to_string(), + completion_item.model_context.clone(), completion_item.plugin_id.clone(), completion_item.script_path.clone(), completion_item.command.clone(), diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 142baeaa37..50fdb0c561 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -14,11 +14,15 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; +use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnSettingsUpdateParams; +use codex_app_server_protocol::TurnSettingsUpdateResponse; +use codex_app_server_protocol::TurnSettingsUpdateStatus; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; @@ -29,7 +33,10 @@ use codex_core::config::ConfigBuilder; use codex_core_plugins::loader::curated_plugin_cache_version; use codex_core_plugins::store::PluginStore; use codex_features::Feature; +use codex_models_manager::bundled_models_response; use codex_plugin::PluginId; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::ReasoningEffort; use core_test_support::responses; use core_test_support::skip_if_no_network; use core_test_support::skip_if_remote; @@ -135,6 +142,7 @@ async fn guardian_review_turns_and_tools_reach_analytics() -> Result<()> { text: "Review three commands".to_string(), text_elements: Vec::new(), }], + effort: Some(ReasoningEffort::High), ..Default::default() }) .await?; @@ -166,6 +174,19 @@ async fn guardian_review_turns_and_tools_reach_analytics() -> Result<()> { timeout(READ_TIMEOUT, app_server.shutdown_gracefully()).await??; let events = captured_analytics_events(&server).await; assert!(!serde_json::to_string(&events)?.contains(PRIVATE)); + let parent_command = events + .iter() + .find(|event| { + event["event_type"] == "codex_command_execution_event" + && event["event_params"]["thread_id"] == thread.id + && event["event_params"]["item_id"] == "parent-first" + }) + .expect("denied parent command analytics"); + let params = &parent_command["event_params"]; + assert_eq!( + json!([params["model_slug"], params["reasoning_effort"]]), + json!(["mock-model", "high"]) + ); let children = events .iter() .filter(|event| event["event_params"]["subagent_source"] == "guardian") @@ -692,6 +713,7 @@ operations: &script_path, r#"test -n "$CODEX_PLUGIN_METRICS_OUTPUT" sleep "${1:-0.3}" +while [ -n "${2:-}" ] && [ ! -f "$2" ]; do sleep 0.01; done printf '%s' '{"version":1,"measurements":[{"name":"findings","value":3,"dimensions":{"severity":"high"}},{"name":"files_scanned","value":17}]}' > "$CODEX_PLUGIN_METRICS_OUTPUT" "#, )?; @@ -724,12 +746,14 @@ async fn assert_plugin_measurement_analytics(remote: bool, background: bool) -> let codex_home = TempDir::new()?; let script_path = write_curated_metrics_plugin(codex_home.path())?.canonicalize()?; + let release_path = codex_home.path().join("release-command"); let mut command = vec![ "/bin/sh".to_string(), script_path.to_string_lossy().into_owned(), ]; if background { command.push("1.0".to_string()); + command.push(release_path.to_string_lossy().into_owned()); } let call_id = "curated-plugin-metrics"; let arguments = serde_json::to_string(&json!({ @@ -742,8 +766,32 @@ async fn assert_plugin_measurement_analytics(remote: bool, background: bool) -> responses::ev_completed("resp-1"), ]); let final_response = create_final_assistant_message_sse_response("done")?; - let server = - create_mock_responses_server_sequence(vec![command_response, final_response]).await; + let pause_response = |id: &str| { + responses::sse(vec![ + responses::ev_response_created(id), + responses::ev_function_call( + id, + "request_user_input", + &json!({ + "questions": [{ + "id": "continue", "header": "Continue", "question": "Continue?", + "options": [ + {"label": "Yes", "description": "Continue the turn."}, + {"label": "No", "description": "Stop the turn."} + ] + }] + }) + .to_string(), + ), + responses::ev_completed(id), + ]) + }; + let mut response_sequence = vec![pause_response("before-command"), command_response]; + if background { + response_sequence.push(pause_response("after-launch")); + } + response_sequence.push(final_response); + let server = create_mock_responses_server_sequence(response_sequence).await; let analytics_server = responses::start_mock_server().await; write_mock_responses_config_toml_with_chatgpt_base_url( @@ -753,11 +801,40 @@ async fn assert_plugin_measurement_analytics(remote: bool, background: bool) -> )?; let config_path = codex_home.path().join("config.toml"); let config = std::fs::read_to_string(&config_path)?; + let model = bundled_models_response()? + .models + .into_iter() + .find(|model| model.slug == "gpt-5.4") + .expect("bundled gpt-5.4 model"); + let models = [ + ("initial-model", ReasoningEffort::Low), + ("invoking-model", ReasoningEffort::High), + ] + .into_iter() + .map(|(slug, effort)| { + let mut model = model.clone(); + model.slug = slug.to_string(); + model.default_reasoning_level = Some(effort); + model + }) + .collect(); + let catalog_path = codex_home.path().join("measurement-models.json"); + std::fs::write( + &catalog_path, + serde_json::to_vec(&ModelsResponse { models })?, + )?; + let catalog_config = format!( + "model_catalog_json = {}", + serde_json::to_string(&catalog_path)? + ); std::fs::write( config_path, format!( - r#"{config} + r#"{catalog_config} +{config} [features] +step_model_switching = true +default_mode_request_user_input = true plugins = true remote_plugin = false unified_exec = true @@ -787,7 +864,7 @@ enabled = true timeout(Duration::from_secs(10), mcp.initialize()).await??; let thread_request = mcp .send_thread_start_request_with_auto_env(ThreadStartParams { - model: Some("mock-model".to_string()), + model: Some("initial-model".to_string()), service_name: Some("codex_work_desktop".to_string()), ..Default::default() }) @@ -813,11 +890,43 @@ enabled = true ..Default::default() }) .await?; - timeout( + let turn_response = timeout( Duration::from_secs(10), mcp.read_stream_until_response_message(RequestId::Integer(turn_request)), ) .await??; + let turn_response: TurnStartResponse = to_response(turn_response)?; + for model in if background { + vec!["invoking-model", "initial-model"] + } else { + vec!["invoking-model"] + } { + let request = timeout( + Duration::from_secs(10), + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ToolRequestUserInput { request_id, .. } = request else { + anyhow::bail!("expected request_user_input, received {request:?}"); + }; + let response: TurnSettingsUpdateResponse = mcp + .request(|request_id| ClientRequest::TurnSettingsUpdate { + request_id, + params: TurnSettingsUpdateParams { + thread_id: thread_id.clone(), + turn_id: turn_response.turn.id.clone(), + model: Some(model.to_string()), + ..Default::default() + }, + }) + .await?; + assert_eq!(response.status, TurnSettingsUpdateStatus::Applied); + mcp.send_response( + request_id, + json!({"answers": {"continue": {"answers": ["Yes"]}}}), + ) + .await?; + } let completed_turn = timeout( Duration::from_secs(10), mcp.read_stream_until_notification_message("turn/completed"), @@ -830,17 +939,7 @@ enabled = true .expect("completed turn id"); if background { - let model_request_bodies = server - .received_requests() - .await - .unwrap_or_default() - .into_iter() - .filter(|request| request.url.path().ends_with("/responses")) - .map(|request| serde_json::from_slice::(&request.body)) - .collect::, _>>()?; - let request_text = serde_json::to_string(&model_request_bodies)?; - assert!(request_text.contains("Process running with session ID ")); - assert!(!request_text.contains("Process exited with code 0")); + std::fs::write(release_path, "release")?; } for measurement_name in ["findings", "files_scanned"] { @@ -859,12 +958,16 @@ enabled = true .await?; assert_eq!( json!({ + "model_slug": command_event["event_params"]["model_slug"], + "reasoning_effort": command_event["event_params"]["reasoning_effort"], "plugin_id": command_event["event_params"]["plugin_id"], "script_path": command_event["event_params"]["script_path"], "item_id": command_event["event_params"]["item_id"], "exit_code": command_event["event_params"]["exit_code"], }), json!({ + "model_slug": "invoking-model", + "reasoning_effort": "high", "plugin_id": METRICS_PLUGIN_ID, "script_path": "scripts/run.sh", "item_id": call_id, @@ -952,10 +1055,12 @@ enabled = true "execution_id", "item_id", "measurement_name", + "model_slug", "number_value", "operation", "originator", "plugin_id", + "reasoning_effort", "thread_id", "turn_id", ] @@ -963,6 +1068,8 @@ enabled = true assert_eq!(event_params["thread_id"], thread_id); assert_eq!(event_params["turn_id"], turn_id); assert_eq!(event_params["originator"], "codex_work_desktop"); + assert_eq!(event_params["model_slug"], "invoking-model"); + assert_eq!(event_params["reasoning_effort"], "high"); } Ok(()) diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 7b4079ec94..72f6a67950 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -20,6 +20,7 @@ mod review_session; mod reviewer_config; mod runtime; +use codex_protocol::items::ModelInvocationContext; use std::sync::Arc; use codex_protocol::config_types::ApprovalsReviewer; @@ -82,6 +83,7 @@ pub(crate) struct GuardianReviewContext { pub(crate) parent_response_id: Option, turn: Arc, environments: TurnEnvironmentSnapshot, + // Model and reasoning inputs are carried for the follow-up Guardian and V2 migrations. pub(crate) model_info: Arc, pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummary, @@ -91,6 +93,17 @@ pub(crate) struct GuardianReviewContext { } impl GuardianReviewContext { + pub(crate) fn model_context(&self) -> ModelInvocationContext { + ModelInvocationContext { + model_slug: self.model_info.slug.clone(), + reasoning_effort: self + .reasoning_effort + .as_ref() + .or(self.model_info.default_reasoning_level.as_ref()) + .map(ToString::to_string), + } + } + pub(crate) fn from_resolved_settings( turn: Arc, settings: &ResolvedStepSettings, diff --git a/codex-rs/core/src/guardian/review_request.rs b/codex-rs/core/src/guardian/review_request.rs index 5e117b2342..4d0426afd6 100644 --- a/codex-rs/core/src/guardian/review_request.rs +++ b/codex-rs/core/src/guardian/review_request.rs @@ -44,6 +44,7 @@ impl ReviewHost for super::super::runtime::ReviewRuntime { Ok(request) => request.clone(), Err(decision) => return Err(decision), }; + let model_context = context.model_context(); let turn = Arc::clone(context.turn()); let GuardianReviewOptions { plugin_attribution_override, @@ -101,6 +102,7 @@ impl ReviewHost for super::super::runtime::ReviewRuntime { reviewed_action: guardian_reviewed_action(&request), action: guardian_assessment_action(&request), review_reason, + model_context, }); session .send_event( diff --git a/codex-rs/core/src/plugins/metrics.rs b/codex-rs/core/src/plugins/metrics.rs index ccea42e67e..c6e7063c1f 100644 --- a/codex-rs/core/src/plugins/metrics.rs +++ b/codex-rs/core/src/plugins/metrics.rs @@ -4,6 +4,7 @@ use crate::tools::sandboxing::ToolCtx; use codex_analytics::PluginMeasurementsInput; use codex_core_plugins::PluginMetricsSidecar; use codex_exec_server::Environment; +use codex_protocol::items::ModelInvocationContext; use codex_utils_path_uri::PathUri; /// Creates a metrics sidecar for one plugin command. @@ -34,6 +35,7 @@ pub(crate) async fn finish_and_track_measurements( exit_code: i32, session: &Session, turn: &TurnContext, + model_context: &ModelInvocationContext, item_id: &str, ) { let Some(metrics_sidecar) = metrics_sidecar else { @@ -50,6 +52,8 @@ pub(crate) async fn finish_and_track_measurements( turn_id: turn.sub_id.clone(), item_id: item_id.to_string(), originator: turn.originator.clone(), + model_slug: Some(model_context.model_slug.clone()), + reasoning_effort: model_context.reasoning_effort.clone(), plugin_id: batch.plugin_id, execution_id: batch.execution_id, operation: batch.operation, diff --git a/codex-rs/core/src/session/elicitation_holders_tests.rs b/codex-rs/core/src/session/elicitation_holders_tests.rs index 2f85db1eec..543bbaeae3 100644 --- a/codex-rs/core/src/session/elicitation_holders_tests.rs +++ b/codex-rs/core/src/session/elicitation_holders_tests.rs @@ -52,6 +52,8 @@ async fn command_approval_holds_an_elicitation_until_response() { .request_command_approval( turn_context.as_ref(), ExecApprovalKind::Command, + crate::guardian::GuardianReviewContext::from(turn_context.clone()) + .model_context(), "call-1".to_string(), /*approval_id*/ None, /*environment_id*/ None, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3e0a931b43..4743503917 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1,4 +1,3 @@ -use crate::context::GuardianContextMode; use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; @@ -23,6 +22,7 @@ use crate::config::ManagedFeatures; use crate::config::resolve_tool_suggest_config_from_layer_stack; use crate::context::ContextualUserFragment; use crate::context::DeveloperInstructions; +use crate::context::GuardianContextMode; use crate::context::GuardianPolicy; use crate::context::ManagedDeveloperInstructions; use crate::context::ModelSwitchInstructions; @@ -107,6 +107,7 @@ use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::items::EnteredReviewModeItem; +use codex_protocol::items::ModelInvocationContext; use codex_protocol::items::SubAgentActivityItem; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; @@ -2790,6 +2791,7 @@ impl Session { &self, turn_context: &TurnContext, kind: ExecApprovalKind, + model_context: ModelInvocationContext, call_id: String, approval_id: Option, environment_id: Option, @@ -2853,6 +2855,7 @@ impl Session { .map(PluginCommandAttribution::serialized_fields) .unzip(); let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + model_context: Some(model_context), kind, call_id, plugin_id, diff --git a/codex-rs/core/src/session/step_context.rs b/codex-rs/core/src/session/step_context.rs index 31085d1089..855d854fd0 100644 --- a/codex-rs/core/src/session/step_context.rs +++ b/codex-rs/core/src/session/step_context.rs @@ -12,6 +12,7 @@ use codex_exec_server::ExecutorCapabilityDiscoverySnapshot; use codex_exec_server::ResolvedSelectedCapabilityRoot; use codex_mcp::McpBinding; use codex_otel::SessionTelemetry; +use codex_protocol::items::ModelInvocationContext; use codex_protocol::protocol::TurnContextItem; /// Request-scoped state that may change between model sampling requests. @@ -43,4 +44,14 @@ impl StepContext { item.summary = self.settings.reasoning_summary; item } + + pub(crate) fn model_context(&self) -> ModelInvocationContext { + ModelInvocationContext { + model_slug: self.settings.model_info.slug.clone(), + reasoning_effort: self + .settings + .effective_reasoning_effort() + .map(|effort| effort.to_string()), + } + } } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 91ed68a51b..a38f26d200 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -188,6 +188,7 @@ pub(crate) async fn execute_user_shell_command( .emit_turn_item_started( turn_context.as_ref(), &TurnItem::CommandExecution(CommandExecutionItem { + model_context: None, id: call_id.clone(), plugin_id: None, script_path: None, @@ -271,6 +272,7 @@ pub(crate) async fn execute_user_shell_command( .emit_turn_item_completed( turn_context.as_ref(), TurnItem::CommandExecution(CommandExecutionItem { + model_context: None, id: call_id, plugin_id: None, script_path: None, @@ -296,6 +298,7 @@ pub(crate) async fn execute_user_shell_command( .emit_turn_item_completed( turn_context.as_ref(), TurnItem::CommandExecution(CommandExecutionItem { + model_context: None, id: call_id.clone(), plugin_id: None, script_path: None, @@ -341,6 +344,7 @@ pub(crate) async fn execute_user_shell_command( .emit_turn_item_completed( turn_context.as_ref(), TurnItem::CommandExecution(CommandExecutionItem { + model_context: None, id: call_id, plugin_id: None, script_path: None, diff --git a/codex-rs/core/src/tools/approvals.rs b/codex-rs/core/src/tools/approvals.rs index 8786c55040..a1513dfccf 100644 --- a/codex-rs/core/src/tools/approvals.rs +++ b/codex-rs/core/src/tools/approvals.rs @@ -701,6 +701,7 @@ impl Session { self.request_command_approval( ctx.review_context.turn(), ExecApprovalKind::Command, + ctx.review_context.model_context(), ctx.call_id.clone(), /*approval_id*/ None, Some(environment_id.clone()), @@ -730,6 +731,7 @@ impl Session { self.request_command_approval( ctx.review_context.turn(), ExecApprovalKind::WriteStdin, + ctx.review_context.model_context(), id.clone(), Some(approval_id.clone()), Some(environment_id.clone()), @@ -761,6 +763,7 @@ impl Session { self.request_command_approval( ctx.review_context.turn(), ExecApprovalKind::Command, + ctx.review_context.model_context(), ctx.call_id.clone(), Some(approval_id.clone()), Some(environment_id.clone()), @@ -833,6 +836,7 @@ impl Session { self.request_command_approval( ctx.review_context.turn(), ExecApprovalKind::Command, + ctx.review_context.model_context(), ctx.call_id.clone(), /*approval_id*/ None, Some(environment_id.clone()), diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 8da8b05288..faa1e6df91 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -17,6 +17,7 @@ use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::items::CommandExecutionItem; use codex_protocol::items::CommandExecutionStatus; use codex_protocol::items::FileChangeItem; +use codex_protocol::items::ModelInvocationContext; use codex_protocol::items::TurnItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::parse_command::ParsedCommand; @@ -43,6 +44,7 @@ pub(super) fn truncate_rejection_message(message: &str) -> String { #[derive(Clone, Copy)] pub(crate) struct ToolEventCtx<'a> { + pub model_context: Option<&'a ModelInvocationContext>, pub session: &'a Session, pub turn: &'a TurnContext, /// Model captured by the step that issued this call, including delayed completion events. @@ -60,6 +62,7 @@ impl<'a> ToolEventCtx<'a> { turn_diff_tracker: Option<&'a SharedTurnDiffTracker>, ) -> Self { Self { + model_context: None, session, turn, model_info, @@ -168,6 +171,7 @@ async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, exec_input: &ExecCommand ctx.turn, &TurnItem::CommandExecution(CommandExecutionItem { id: ctx.call_id.to_string(), + model_context: ctx.model_context.cloned(), plugin_id, script_path, process_id: exec_input.process_id.map(str::to_owned), @@ -581,6 +585,7 @@ async fn emit_exec_end( ctx.turn, TurnItem::CommandExecution(CommandExecutionItem { id: ctx.call_id.to_string(), + model_context: ctx.model_context.cloned(), plugin_id, script_path, process_id: exec_input.process_id.map(str::to_owned), diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index ba91d72444..245bdb0d54 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -178,6 +178,7 @@ pub(crate) fn spawn_exit_watcher( let session_ref = Arc::clone(&context.session); let turn_ref = Arc::clone(&context.step_context.turn); let model_info = Arc::clone(&context.step_context.settings.model_info); + let model_context = context.step_context.model_context(); let call_id = context.call_id.clone(); let exit_token = process.cancellation_token(); let output_drained = process.output_drained_notify(); @@ -223,6 +224,7 @@ pub(crate) fn spawn_exit_watcher( exit_code, &session_ref, &turn_ref, + &model_context, &call_id, ) .await; diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index c04c1a897a..676acd9f96 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -276,11 +276,13 @@ struct InitialExecCommandGuard { impl InitialExecCommandGuard { async fn finish_plugin_metrics(&mut self, context: &UnifiedExecContext, exit_code: i32) { + let model_context = context.step_context.model_context(); finish_and_track_measurements( self.metrics_sidecar.take(), exit_code, &context.session, &context.step_context.turn, + &model_context, &context.call_id, ) .await; @@ -530,13 +532,15 @@ impl UnifiedExecProcessManager { }); let transcript = Arc::new(tokio::sync::Mutex::new(HeadTailBuffer::default())); - let event_ctx = ToolEventCtx::new( + let model_context = context.step_context.model_context(); + let mut event_ctx = ToolEventCtx::new( context.session.as_ref(), context.step_context.turn.as_ref(), &context.step_context.settings.model_info, &context.call_id, /*turn_diff_tracker*/ None, ); + event_ctx.model_context = Some(&model_context); let plugin_attribution = if request.turn_environment.environment.is_remote() { let file_system = request.turn_environment.environment.get_filesystem(); context @@ -727,6 +731,7 @@ impl UnifiedExecProcessManager { exit_code.unwrap_or(-1), &context.session, &context.step_context.turn, + &model_context, &context.call_id, ) .await; diff --git a/codex-rs/core/tests/suite/context_annotations.rs b/codex-rs/core/tests/suite/context_annotations.rs index 69b7d32e68..aeb4a52882 100644 --- a/codex-rs/core/tests/suite/context_annotations.rs +++ b/codex-rs/core/tests/suite/context_annotations.rs @@ -76,6 +76,7 @@ async fn first_request_item_types_roles_and_content_annotations() -> Result<()> .submit(Op::ApproveGuardianDeniedAction { event: GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "guardian-review".to_string(), target_item_id: None, plugin_id: None, diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 737822d4ef..8068985fec 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -169,6 +169,7 @@ fn turn_started_emits_turn_started_event() { fn command_execution_started_and_completed_translate_to_thread_events() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let command_item = ThreadItem::CommandExecution { + model_context: None, id: "cmd-1".to_string(), command: "ls".to_string(), cwd: test_path_buf("/tmp/project").abs().into(), @@ -211,6 +212,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() { let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( ItemCompletedNotification { item: ThreadItem::CommandExecution { + model_context: None, id: "cmd-1".to_string(), command: "ls".to_string(), cwd: test_path_buf("/tmp/project").abs().into(), @@ -1348,6 +1350,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { let started = processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { item: ThreadItem::CommandExecution { + model_context: None, id: "cmd-1".to_string(), command: "ls".to_string(), cwd: test_path_buf("/tmp/project").abs().into(), @@ -1390,6 +1393,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { id: "turn-1".to_string(), items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::CommandExecution { + model_context: None, id: "cmd-1".to_string(), command: "ls".to_string(), cwd: test_path_buf("/tmp/project").abs().into(), diff --git a/codex-rs/ext/guardian-reviewer/src/reporting.rs b/codex-rs/ext/guardian-reviewer/src/reporting.rs index bcc974cd47..531d322247 100644 --- a/codex-rs/ext/guardian-reviewer/src/reporting.rs +++ b/codex-rs/ext/guardian-reviewer/src/reporting.rs @@ -12,6 +12,7 @@ use codex_extension_api::ExtensionData; use codex_otel::SessionTelemetry; use codex_protocol::approvals::GuardianAssessmentAction; use codex_protocol::approvals::GuardianReviewReason; +use codex_protocol::items::ModelInvocationContext; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::GuardianAssessmentDecisionSource; use codex_protocol::protocol::GuardianAssessmentEvent; @@ -36,6 +37,7 @@ pub struct ReviewMetadata { pub reviewed_action: GuardianReviewedAction, pub action: GuardianAssessmentAction, pub review_reason: GuardianReviewReason, + pub model_context: ModelInvocationContext, } pub struct ReviewReport { @@ -58,6 +60,7 @@ impl ReviewReport { ); let started = GuardianAssessmentEvent { review_reason: Some(metadata.review_reason), + model_context: Some(metadata.model_context), id: metadata.review_id, target_item_id: metadata.target_item_id, plugin_id: metadata.plugin_id, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 3325a90539..30ace64f8a 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -208,6 +208,10 @@ pub struct GuardianAssessmentEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub review_reason: Option, + #[serde(skip)] + #[schemars(skip)] + #[ts(skip)] + pub model_context: Option, /// Stable identifier for this guardian review lifecycle. pub id: String, /// Thread item being reviewed, when the review maps to a concrete item. @@ -264,6 +268,10 @@ pub enum ExecApprovalKind { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { + #[serde(skip)] + #[schemars(skip)] + #[ts(skip)] + pub model_context: Option, /// Missing on older events, which retain command approval semantics. #[serde(default)] pub kind: ExecApprovalKind, diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 362854f8db..b6c18412f4 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -232,8 +232,19 @@ pub fn is_safe_plugin_relative_path(path: &str) -> bool { }) } +/// Immutable model labels carried within command lifecycle events for analytics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelInvocationContext { + pub model_slug: String, + pub reasoning_effort: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct CommandExecutionItem { + #[serde(skip)] + #[schemars(skip)] + #[ts(skip)] + pub model_context: Option, pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 8fb81c28b7..ceeaa80e31 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -5516,6 +5516,7 @@ mod tests { turn_id: "turn-1".into(), started_at_ms: 10, item: TurnItem::CommandExecution(CommandExecutionItem { + model_context: None, id: "exec-1".into(), plugin_id: Some("sample@openai-curated".into()), script_path: Some("scripts/run.py".into()), @@ -5542,6 +5543,7 @@ mod tests { started_at_ms: Some(10), completed_at_ms: 20, item: TurnItem::CommandExecution(CommandExecutionItem { + model_context: None, id: "exec-1".into(), plugin_id: Some("sample@openai-curated".into()), script_path: Some("scripts/run.py".into()), diff --git a/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs b/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs index 27d4c885d0..589509f7ff 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/legacy_event.rs @@ -213,6 +213,7 @@ pub(super) fn completed_item( )), EventMsg::ExecCommandEnd(event) => Some(( TurnItem::CommandExecution(CommandExecutionItem { + model_context: None, id: event.call_id.clone(), plugin_id: event.plugin_id.clone(), script_path: event.script_path.clone(), diff --git a/codex-rs/tui/src/app/agent_status_feed_tests.rs b/codex-rs/tui/src/app/agent_status_feed_tests.rs index 76fc172d1b..35d81abfe2 100644 --- a/codex-rs/tui/src/app/agent_status_feed_tests.rs +++ b/codex-rs/tui/src/app/agent_status_feed_tests.rs @@ -10,6 +10,7 @@ fn agent_status_uses_bounded_buffered_activity() { store.push_notification(ServerNotification::ItemCompleted( ItemCompletedNotification { item: ThreadItem::CommandExecution { + model_context: None, id: "command-1".to_string(), command: "cargo test -p codex-tui".to_string(), cwd: AbsolutePathBuf::try_from("/workspace") diff --git a/codex-rs/tui/src/auto_review_denials.rs b/codex-rs/tui/src/auto_review_denials.rs index d6c5ee4229..a969e5befc 100644 --- a/codex-rs/tui/src/auto_review_denials.rs +++ b/codex-rs/tui/src/auto_review_denials.rs @@ -95,6 +95,7 @@ mod tests { fn denied_event(id: usize) -> GuardianAssessmentEvent { GuardianAssessmentEvent { review_reason: None, + model_context: None, id: format!("review-{id}"), target_item_id: None, plugin_id: None, diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 951077dd9b..5021d03698 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -194,6 +194,7 @@ mod tests { fn command_execution(call_id: &str) -> ThreadItem { ThreadItem::CommandExecution { + model_context: None, id: call_id.to_string(), command: "true".to_string(), cwd: AbsolutePathBuf::current_dir().expect("current dir").into(), diff --git a/codex-rs/tui/src/chatwidget/protocol_requests.rs b/codex-rs/tui/src/chatwidget/protocol_requests.rs index 89f78ea6e2..e76b50b6ee 100644 --- a/codex-rs/tui/src/chatwidget/protocol_requests.rs +++ b/codex-rs/tui/src/chatwidget/protocol_requests.rs @@ -96,6 +96,7 @@ impl ChatWidget { self.on_guardian_assessment(GuardianAssessmentEvent { review_reason: None, + model_context: None, id, target_item_id: None, plugin_id: None, diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 247227ee87..edace675b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1080,6 +1080,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { turn_id: "turn-1".to_string(), started_at_ms: 0, item: AppServerThreadItem::CommandExecution { + model_context: None, id: "cmd-1".to_string(), command: command.clone(), cwd: test_path_buf("/tmp").abs().into(), @@ -1104,6 +1105,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { turn_id: "turn-1".to_string(), completed_at_ms: 0, item: AppServerThreadItem::CommandExecution { + model_context: None, id: "cmd-1".to_string(), command, cwd: test_path_buf("/tmp").abs().into(), diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 45906e8c99..c8cdfe9248 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -164,6 +164,7 @@ async fn replayed_commands_preserve_individual_output_and_failure_status() { let cwd = chat.config.cwd.clone(); let replayed_command = |id: &str, output: &str, source: ExecCommandSource| AppServerThreadItem::CommandExecution { + model_context: None, id: id.to_string(), command: format!("printf {output}"), cwd: cwd.clone().into(), @@ -612,6 +613,7 @@ async fn exec_end_without_begin_uses_event_command() { handle_exec_end( &mut chat, AppServerThreadItem::CommandExecution { + model_context: None, id: "call-orphan".to_string(), command: codex_shell_command::parse_command::shlex_join(&command), cwd: cwd.into(), diff --git a/codex-rs/tui/src/chatwidget/tests/guardian.rs b/codex-rs/tui/src/chatwidget/tests/guardian.rs index 1cac151689..d43ffc7d1d 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -4,6 +4,7 @@ use pretty_assertions::assert_eq; fn auto_review_denial_event() -> GuardianAssessmentEvent { GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "auto-review-recent-1".into(), target_item_id: Some("target-auto-review-recent-1".into()), plugin_id: None, @@ -33,6 +34,7 @@ fn guardian_command_event( let terminal = status != GuardianAssessmentStatus::InProgress; GuardianAssessmentEvent { review_reason: None, + model_context: None, id: id.to_string(), target_item_id: Some(format!("{id}-target")), plugin_id: None, @@ -247,6 +249,7 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() { chat.on_guardian_assessment(GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "guardian-1".into(), target_item_id: Some("guardian-target-1".into()), plugin_id: None, @@ -264,6 +267,7 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() { chat.on_warning("Automatic approval review denied (risk: high): The planned action would transmit the full contents of a workspace source file (`core/src/codex.rs`) to `https://example.com`, which is an external and untrusted endpoint."); chat.on_guardian_assessment(GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "guardian-1".into(), target_item_id: Some("guardian-target-1".into()), plugin_id: None, @@ -318,6 +322,7 @@ async fn guardian_approved_exec_is_hidden_from_history() { ); chat.on_guardian_assessment(GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "thread:child-thread:guardian-1".into(), target_item_id: Some("guardian-approved-target".into()), plugin_id: None, @@ -376,6 +381,7 @@ async fn guardian_approved_request_permissions_clears_status_without_history() { chat.on_guardian_assessment(GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "guardian-request-permissions".into(), target_item_id: None, plugin_id: None, @@ -403,6 +409,7 @@ async fn guardian_approved_request_permissions_clears_status_without_history() { chat.on_guardian_assessment(GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "guardian-request-permissions".into(), target_item_id: None, plugin_id: None, @@ -456,6 +463,7 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() { chat.on_guardian_assessment(GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "guardian-1".into(), target_item_id: Some("guardian-target-1".into()), plugin_id: None, @@ -473,6 +481,7 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() { chat.on_warning("Automatic approval review timed out while evaluating the requested approval."); chat.on_guardian_assessment(GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "guardian-1".into(), target_item_id: Some("guardian-target-1".into()), plugin_id: None, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 3b54b8cfcb..c750b86fd9 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -883,6 +883,7 @@ pub(super) fn begin_exec_with_source( .map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd)) .collect(); let item = AppServerThreadItem::CommandExecution { + model_context: None, id: call_id.to_string(), command: codex_shell_command::parse_command::shlex_join(&command), cwd: chat.config.cwd.clone().into(), @@ -908,6 +909,7 @@ pub(super) fn begin_unified_exec_startup( ) -> AppServerThreadItem { let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; let item = AppServerThreadItem::CommandExecution { + model_context: None, id: call_id.to_string(), command: codex_shell_command::parse_command::shlex_join(&command), cwd: chat.config.cwd.clone().into(), @@ -1142,6 +1144,7 @@ pub(super) fn end_exec( handle_exec_end( chat, AppServerThreadItem::CommandExecution { + model_context: None, id, command, cwd, diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index c852182d13..bc3f249767 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -535,6 +535,7 @@ async fn mcp_startup_complete_preserves_review_status() { chat.on_guardian_assessment(GuardianAssessmentEvent { review_reason: None, + model_context: None, id: "guardian-1".to_string(), target_item_id: Some("guardian-target-1".to_string()), plugin_id: None, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index edf288001b..6f5e038a32 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -5668,6 +5668,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { handle_exec_begin( &mut chat, AppServerThreadItem::CommandExecution { + model_context: None, id: "c1".into(), command: codex_shell_command::parse_command::shlex_join(&command), cwd: cwd.clone().into(), @@ -5685,6 +5686,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { handle_exec_end( &mut chat, AppServerThreadItem::CommandExecution { + model_context: None, id: "c1".into(), command: codex_shell_command::parse_command::shlex_join(&command), cwd: cwd.into(),