diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0a11412ef..7ceda36a6 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -391,6 +391,7 @@ fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedCo sandbox_network_access: true, collaboration_mode: ModeKind::Plan, personality: None, + workspace_kind: None, is_first_turn: true, } } @@ -3255,6 +3256,7 @@ fn turn_event_serializes_expected_shape() { sandbox_network_access: true, collaboration_mode: Some("plan"), personality: Some("pragmatic".to_string()), + workspace_kind: Some("projectless".to_string()), num_input_images: 2, is_first_turn: true, status: Some(TurnStatus::Completed), @@ -3320,6 +3322,7 @@ fn turn_event_serializes_expected_shape() { "sandbox_network_access": true, "collaboration_mode": "plan", "personality": "pragmatic", + "workspace_kind": "projectless", "num_input_images": 2, "is_first_turn": true, "status": "completed", @@ -3640,6 +3643,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert!(payload["event_params"].get("product_client_id").is_none()); assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["workspace_kind"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d2e7d8054..61fe75ac2 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -794,6 +794,7 @@ pub(crate) struct CodexTurnEventParams { pub(crate) sandbox_network_access: bool, pub(crate) collaboration_mode: Option<&'static str>, pub(crate) personality: Option, + pub(crate) workspace_kind: Option, pub(crate) num_input_images: usize, pub(crate) is_first_turn: bool, pub(crate) status: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 4d298ffdb..b05610850 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -85,6 +85,7 @@ pub struct TurnResolvedConfigFact { pub sandbox_network_access: bool, pub collaboration_mode: ModeKind, pub personality: Option, + pub workspace_kind: Option, pub is_first_turn: bool, } diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 46f69c59f..5b4117853 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2483,6 +2483,7 @@ fn codex_turn_event_params( sandbox_network_access, collaboration_mode, personality, + workspace_kind, is_first_turn, } = resolved_config; let token_usage = turn_state.token_usage.clone(); @@ -2515,6 +2516,7 @@ fn codex_turn_event_params( sandbox_network_access, collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), personality: personality_mode(personality), + workspace_kind, num_input_images, is_first_turn, status: completed.status, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 0c2c7b71c..9f6a027a7 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -855,6 +855,10 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { url: "https://example.com/a.png".to_string(), detail: None, }], + responsesapi_client_metadata: Some(HashMap::from([( + "workspace_kind".to_string(), + "projectless".to_string(), + )])), ..Default::default() }) .await?; @@ -882,6 +886,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["workspace_kind"], "projectless"); assert_eq!(event["event_params"]["ephemeral"], false); assert_eq!(event["event_params"]["thread_source"], "user"); assert_eq!(event["event_params"]["initialization_mode"], "new"); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 62f8d9a49..498e65c7b 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -640,6 +640,7 @@ async fn track_turn_resolved_config_analytics( sandbox_network_access: turn_context.network_sandbox_policy().is_enabled(), collaboration_mode: turn_context.collaboration_mode.mode, personality: turn_context.personality, + workspace_kind: turn_context.turn_metadata_state.workspace_kind(), is_first_turn, }); } diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 7296d6d3f..e66a1fbb5 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -33,6 +33,7 @@ const MODEL_KEY: &str = "model"; const REASONING_EFFORT_KEY: &str = "reasoning_effort"; const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; const USER_INPUT_REQUESTED_DURING_TURN_KEY: &str = "user_input_requested_during_turn"; +const WORKSPACE_KIND_KEY: &str = "workspace_kind"; const REQUEST_KIND_KEY: &str = "request_kind"; const COMPACTION_KEY: &str = "compaction"; const WINDOW_ID_KEY: &str = "window_id"; @@ -443,6 +444,14 @@ impl TurnMetadataState { Some(responsesapi_client_metadata); } + pub(crate) fn workspace_kind(&self) -> Option { + self.responsesapi_client_metadata + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .and_then(|metadata| metadata.get(WORKSPACE_KIND_KEY).cloned()) + } + pub(crate) fn set_turn_started_at_unix_ms(&self, turn_started_at_unix_ms: i64) { *self .turn_started_at_unix_ms diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index ffbeb83d2..32a559848 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -546,6 +546,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( state.set_responsesapi_client_metadata(HashMap::from([ ("fiber_run_id".to_string(), "fiber-123".to_string()), ("origin".to_string(), "東京".to_string()), + ("workspace_kind".to_string(), "projectless".to_string()), ("model".to_string(), "client-supplied".to_string()), ( "reasoning_effort".to_string(), @@ -580,6 +581,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(json["fiber_run_id"].as_str(), Some("fiber-123")); assert_eq!(json["origin"].as_str(), Some("東京")); + assert_eq!(json["workspace_kind"].as_str(), Some("projectless")); assert_eq!(json["model"].as_str(), Some("client-supplied")); assert_eq!(json["reasoning_effort"].as_str(), Some("client-supplied")); assert_eq!(json["session_id"].as_str(), Some("session-a")); @@ -619,6 +621,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); assert!(meta.get(WINDOW_ID_KEY).is_none()); + assert_eq!(state.workspace_kind().as_deref(), Some("projectless")); } #[test]