diff --git a/codex-rs/core/src/responses_metadata.rs b/codex-rs/core/src/responses_metadata.rs index 5989d4eed1..fbd3661c3b 100644 --- a/codex-rs/core/src/responses_metadata.rs +++ b/codex-rs/core/src/responses_metadata.rs @@ -39,6 +39,7 @@ pub(crate) const LEGACY_CODE_MODE_TOOL_NAMES_KEY: &str = "code_mode_tool_names"; pub(crate) const TOOL_NAMESPACES_INFO_KEY: &str = "tool_namespaces_info"; pub(crate) const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; pub(crate) const HISTORY_INGEST_REQUESTED_KEY: &str = "history_ingest_requested"; +pub(crate) const ANALYTICS_ENABLED_KEY: &str = "analytics_enabled"; pub(crate) const FORKED_FROM_THREAD_ID_KEY: &str = "forked_from_thread_id"; pub(crate) const FORKED_FROM_ORDINAL_EXCLUSIVE_KEY: &str = "forked_from_ordinal_exclusive"; @@ -78,6 +79,7 @@ const RESERVED_METADATA_KEYS: &[&str] = &[ TOOL_NAMESPACES_INFO_KEY, TURN_STARTED_AT_UNIX_MS_KEY, HISTORY_INGEST_REQUESTED_KEY, + ANALYTICS_ENABLED_KEY, FORKED_FROM_THREAD_ID_KEY, FORKED_FROM_ORDINAL_EXCLUSIVE_KEY, PARENT_THREAD_ID_KEY, @@ -95,8 +97,11 @@ const RESERVED_METADATA_KEYS: &[&str] = &[ ]; // These keys were previously valid user configuration. Accept existing configs while filtering // their values before constructing Core-owned request metadata. -const BACKWARD_COMPATIBLE_RESERVED_METADATA_KEYS: &[&str] = - &[WINDOW_NUMBER_KEY, FORKED_FROM_ORDINAL_EXCLUSIVE_KEY]; +const BACKWARD_COMPATIBLE_RESERVED_METADATA_KEYS: &[&str] = &[ + WINDOW_NUMBER_KEY, + FORKED_FROM_ORDINAL_EXCLUSIVE_KEY, + ANALYTICS_ENABLED_KEY, +]; const MAX_EXTRA_METADATA_ENTRIES: usize = 16; const MAX_EXTRA_METADATA_KEY_BYTES: usize = 64; pub(crate) const MAX_EXTRA_METADATA_VALUE_BYTES: usize = 128; @@ -247,6 +252,9 @@ pub struct CodexResponsesMetadata { pub(crate) tool_namespaces_info: Option, pub(crate) turn_started_at_unix_ms: Option, pub(crate) history_ingest_requested: Option, + /// Selected session analytics client's collection state, independent of event eligibility or delivery. + /// Absent when the request has no initialized session analytics context. + pub(crate) analytics_enabled: Option, pub(crate) extra: BTreeMap, } @@ -287,6 +295,7 @@ impl CodexResponsesMetadata { tool_namespaces_info: None, turn_started_at_unix_ms: None, history_ingest_requested: None, + analytics_enabled: None, extra: BTreeMap::new(), } } @@ -413,6 +422,7 @@ impl CodexResponsesMetadata { tool_namespaces_info: self.tool_namespaces_info.as_ref(), turn_started_at_unix_ms: self.turn_started_at_unix_ms, history_ingest_requested: self.history_ingest_requested, + analytics_enabled: self.analytics_enabled, compaction, // Extra metadata enriches the Codex turn metadata blob, not literal top-level // Responses client_metadata. Product metadata is validated while loading config; @@ -560,6 +570,8 @@ struct CodexTurnMetadataPayload<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] history_ingest_requested: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + analytics_enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] compaction: Option, #[serde(flatten)] extra: &'a BTreeMap, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 1f2a4b0517..3a6cbfd137 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -647,6 +647,7 @@ impl Session { CodexResponsesMetadata { window_number: Some(window_number), context_window_id: Some(context_window_id), + analytics_enabled: Some(self.services.analytics_events_client.is_enabled()), history_ingest_requested: turn_context .config .token_budget diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 169b055be5..6efd613754 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -97,6 +97,7 @@ use std::collections::BTreeMap; use tracing::Span; use crate::connectors::AppInfo; +use crate::responses_metadata::CodexResponsesRequestKind; use crate::rollout::recorder::RolloutRecorder; use crate::state::ActiveTurn; use crate::state::TaskKind; @@ -6492,6 +6493,23 @@ pub(crate) async fn build_world_state_from_turn_context( .expect("world state should build") } +#[tokio::test] +async fn responses_metadata_uses_selected_harness_analytics_client() { + let (mut session, mut turn_context) = make_session_and_context().await; + for enabled in [true, false] { + session.services.analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&session.services.auth_manager), + turn_context.config.chatgpt_base_url.clone(), + Some(enabled), + ); + Arc::make_mut(&mut turn_context.config).analytics_enabled = Some(!enabled); + let metadata = session + .responses_metadata(&turn_context, CodexResponsesRequestKind::Turn) + .await; + assert_eq!(metadata.analytics_enabled, Some(enabled)); + } +} + // todo: use online model info pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let (tx_event, _rx_event) = async_channel::unbounded(); diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index be1c2a98d7..01de9c70df 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -1,5 +1,6 @@ use super::*; +use crate::responses_metadata::ANALYTICS_ENABLED_KEY; use crate::responses_metadata::AUTO_REVIEW_ENABLED_KEY; use crate::responses_metadata::CONTEXT_WINDOW_ID_KEY; use crate::responses_metadata::CodexResponsesRequestKind; @@ -730,6 +731,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( ); state.set_responses_api_metadata(BTreeMap::from([ ("codex_security_surface".to_string(), "sdk".to_string()), + (ANALYTICS_ENABLED_KEY.to_string(), "false".to_string()), ("source".to_string(), " Configured_Source ".to_string()), ( WINDOW_NUMBER_KEY.to_string(), @@ -749,6 +751,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( "client-supplied".to_string(), ), ("fiber_run_id".to_string(), "fiber-123".to_string()), + (ANALYTICS_ENABLED_KEY.to_string(), "true".to_string()), ("origin".to_string(), "東京".to_string()), ("workspace_kind".to_string(), "projectless".to_string()), ("source".to_string(), "client-source".to_string()), @@ -838,6 +841,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( let json: Value = serde_json::from_str(&header).expect("json"); assert_eq!(json["fiber_run_id"].as_str(), Some("fiber-123")); + assert!(json.get(ANALYTICS_ENABLED_KEY).is_none()); assert_eq!(json["origin"].as_str(), Some("東京")); assert_eq!(json["workspace_kind"].as_str(), Some("projectless")); assert_eq!(json["codex_security_surface"].as_str(), Some("sdk")); @@ -955,6 +959,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert!(meta.get(ROOT_TURN_ID_KEY).is_none()); assert!(meta.get(WINDOW_ID_KEY).is_none()); assert!(meta.get("codex_security_surface").is_none()); + assert!(meta.get(ANALYTICS_ENABLED_KEY).is_none()); assert_eq!(state.workspace_kind().as_deref(), Some("projectless")); assert_eq!( (state.turn_trigger(), state.codex_turn_source()), @@ -1108,8 +1113,12 @@ fn responses_api_metadata_rejects_reserved_keys() { } #[test] -fn responses_api_metadata_accepts_previously_valid_rollout_position_keys() { - for legacy_key in [WINDOW_NUMBER_KEY, FORKED_FROM_ORDINAL_EXCLUSIVE_KEY] { +fn responses_api_metadata_accepts_previously_valid_reserved_keys() { + for legacy_key in [ + WINDOW_NUMBER_KEY, + FORKED_FROM_ORDINAL_EXCLUSIVE_KEY, + ANALYTICS_ENABLED_KEY, + ] { assert_eq!( validate_extra_metadata( BTreeMap::from([(legacy_key.to_string(), "legacy-value".to_string())]).iter() diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 9beca451bd..be184753e7 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -460,7 +460,11 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e() responses::ev_completed("resp-1"), ]); - let test = test_codex().build(&server).await.expect("build test codex"); + let test = test_codex() + .with_config(|config| config.analytics_enabled = Some(true)) + .build(&server) + .await + .expect("build test codex"); let cwd = test.cwd_path(); let first_request = responses::mount_sse_once(&server, response_body.clone()).await; @@ -508,6 +512,13 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e() .expect("request body should include x-codex-turn-metadata"), ) .expect("body x-codex-turn-metadata should be valid JSON"); + assert_eq!( + ( + initial_parsed["analytics_enabled"].as_bool(), + body_metadata["analytics_enabled"].as_bool(), + ), + (Some(true), Some(true)), + ); assert_eq!( body_metadata .get("sandbox_mode") diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index e600b21ae4..466c20bf4b 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -185,7 +185,10 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create( let mut builder = test_codex() .with_model("gpt-5.2") - .with_config(move |config| config.update_plan_enabled = update_plan_enabled); + .with_config(move |config| { + config.update_plan_enabled = update_plan_enabled; + config.analytics_enabled = Some(false); + }); let test = builder.build_with_websocket_server(&server).await?; test.submit_turn_with_policy("hello", test.config.legacy_sandbox_policy()) .await?; @@ -214,6 +217,7 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create( .expect("warmup turn metadata"), )?; assert_eq!(warmup_metadata["request_kind"].as_str(), Some("prewarm")); + assert_eq!(warmup_metadata["analytics_enabled"].as_bool(), Some(false)); assert_eq!( warmup_metadata["window_id"].as_str(), warmup["client_metadata"]["x-codex-window-id"].as_str() @@ -225,12 +229,14 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create( "expected request tools to be populated" ); assert_eq!(turn["type"].as_str(), Some("response.create")); + assert_eq!(turn.get("generate"), None); let turn_metadata: Value = serde_json::from_str( turn["client_metadata"]["x-codex-turn-metadata"] .as_str() .expect("turn metadata"), )?; assert_eq!(turn_metadata["request_kind"].as_str(), Some("turn")); + assert_eq!(turn_metadata["analytics_enabled"].as_bool(), Some(false)); assert_eq!(warmup_metadata["window_number"].as_u64(), Some(0)); assert_eq!( warmup_metadata["window_number"],