diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1c6c94097d..0ac74e9b8c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAcceptedLineFingerprintsEventRequest; use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexAppUsedMetadata; use crate::events::CodexCommandExecutionEventParams; use crate::events::CodexCommandExecutionEventRequest; use crate::events::CodexCompactionEventRequest; @@ -66,6 +67,7 @@ use crate::facts::CompactionTrigger; use crate::facts::ControlToolCallFact; use crate::facts::ControlToolCallStatus; use crate::facts::CustomAnalyticsFact; +use crate::facts::ElicitationType; use crate::facts::ExternalAgentConfigImportCompletedInput; use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunFact; @@ -75,6 +77,7 @@ use crate::facts::ImagePreparationFact; use crate::facts::ImagePreparationMetadata; use crate::facts::InputError; use crate::facts::InvocationType; +use crate::facts::McpToolCallElicitation; use crate::facts::PluginInstallFailedInput; use crate::facts::PluginInstallRequestSource; use crate::facts::PluginInstallRequested; @@ -1185,14 +1188,17 @@ fn app_used_event_serializes_expected_shape() { let tracking = test_tracking_context("thread-2", "turn-2"); let event = TrackEventRequest::AppUsed(CodexAppUsedEventRequest { event_type: "codex_app_used", - event_params: codex_app_metadata( - &tracking, - AppInvocation { - connector_id: Some("drive".to_string()), - app_name: Some("Google Drive".to_string()), - invocation_type: Some(InvocationType::Implicit), - }, - ), + event_params: CodexAppUsedMetadata { + app: codex_app_metadata( + &tracking, + AppInvocation { + connector_id: Some("drive".to_string()), + app_name: Some("Google Drive".to_string()), + invocation_type: Some(InvocationType::Implicit), + }, + ), + elicitation_type: None, + }, }); let payload = serde_json::to_value(&event).expect("serialize app used event"); @@ -1208,7 +1214,8 @@ fn app_used_event_serializes_expected_shape() { "app_name": "Google Drive", "product_client_id": TEST_PRODUCT_CLIENT_ID, "invoke_type": "implicit", - "model_slug": "gpt-5" + "model_slug": "gpt-5", + "elicitation_type": null } }) ); @@ -1641,28 +1648,6 @@ fn compaction_implementation_serializes_remote_v2() { assert_eq!(payload, json!("responses_compaction_v2")); } -#[test] -fn app_used_dedupe_is_keyed_by_turn_and_connector() { - let (sender, _receiver) = mpsc::channel(1); - let queue = AnalyticsEventsQueue { - sender, - app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), - plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), - }; - let app = AppInvocation { - connector_id: Some("calendar".to_string()), - app_name: Some("Calendar".to_string()), - invocation_type: Some(InvocationType::Implicit), - }; - - let turn_1 = test_tracking_context("thread-1", "turn-1"); - let turn_2 = test_tracking_context("thread-1", "turn-2"); - - assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), true); - assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), false); - assert_eq!(queue.should_enqueue_app_used(&turn_2, &app), true); -} - #[test] fn thread_initialized_event_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(ThreadInitializedEvent { @@ -4715,6 +4700,7 @@ async fn reducer_ingests_app_and_plugin_facts() { app_name: Some("Drive".to_string()), invocation_type: Some(InvocationType::Implicit), }, + elicitation_type: Some(ElicitationType::AuthOrLink), })), &mut events, ) @@ -4733,6 +4719,10 @@ async fn reducer_ingests_app_and_plugin_facts() { assert_eq!(payload.as_array().expect("events array").len(), 3); assert_eq!(payload[0]["event_type"], "codex_app_mentioned"); assert_eq!(payload[1]["event_type"], "codex_app_used"); + assert_eq!( + payload[1]["event_params"]["elicitation_type"], + "auth_or_link" + ); assert_eq!(payload[2]["event_type"], "codex_plugin_used"); assert_eq!( payload[0]["event_params"]["product_client_id"], @@ -5885,6 +5875,129 @@ async fn turn_event_counts_completed_tool_items() { assert_eq!(payload["event_params"]["image_generation_count"], json!(1)); } +#[tokio::test] +async fn mcp_elicitation_classification_survives_turn_completion_and_preserves_call_grain() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + ingest_turn_prerequisites( + &mut reducer, + &mut events, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut items = Vec::new(); + for (item_id, connector_id, elicitation_type) in [ + ("auth", "calendar", Some(ElicitationType::AuthOrLink)), + ("retry", "calendar", None), + ("denied", "drive", Some(ElicitationType::Approval)), + ] { + let item = ThreadItem::McpToolCall { + id: item_id.to_string(), + server: "server".to_string(), + tool: "search".to_string(), + status: McpToolCallStatus::Completed, + arguments: json!({ "token": "synthetic-private-input" }), + app_context: Some(McpToolCallAppContext { + connector_id: connector_id.to_string(), + link_id: None, + resource_uri: None, + app_name: None, + action_name: None, + }), + mcp_app_resource_uri: None, + plugin_id: None, + read_only_hint: None, + result: None, + error: None, + duration_ms: Some(2), + }; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( + ItemStartedNotification { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + started_at_ms: 998, + item: item.clone(), + }, + ))), + &mut events, + ) + .await; + if let Some(elicitation_type) = elicitation_type { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::McpToolCallElicitation( + McpToolCallElicitation { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + item_id: item_id.to_string(), + elicitation_type, + }, + )), + &mut events, + ) + .await; + } + items.push(item); + } + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut events, + ) + .await; + for item in items { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + completed_at_ms: 1_000, + item, + }, + ))), + &mut events, + ) + .await; + } + + let payload = serde_json::to_value(&events).expect("serialize analytics events"); + let classifications = payload + .as_array() + .expect("analytics events array") + .iter() + .filter(|event| event["event_type"] == "codex_mcp_tool_call_event") + .map(|event| { + json!({ + "item_id": event["event_params"]["item_id"], + "connector_id": event["event_params"]["connector_id"], + "elicitation_type": event["event_params"].get("elicitation_type") + .expect("elicitation_type must be present"), + }) + }) + .collect::>(); + assert_eq!( + classifications, + vec![ + json!({"item_id": "auth", "connector_id": "calendar", "elicitation_type": "auth_or_link"}), + json!({"item_id": "retry", "connector_id": "calendar", "elicitation_type": null}), + json!({"item_id": "denied", "connector_id": "drive", "elicitation_type": "approval"}), + ] + ); + assert!(!payload.to_string().contains("synthetic-private-input")); +} + #[tokio::test] async fn turn_and_tool_events_read_current_trusted_root() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 6895825c87..410cf7def4 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,11 +13,13 @@ use crate::facts::ArtifactOperation; use crate::facts::ArtifactOperationInput; use crate::facts::CodexGoalEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::ElicitationType; use crate::facts::ExternalAgentConfigImportCompletedInput; use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunFact; use crate::facts::HookRunInput; use crate::facts::ImagePreparationFact; +use crate::facts::McpToolCallElicitation; use crate::facts::PluginInstallFailedInput; use crate::facts::PluginInstallRequested; use crate::facts::PluginInstallRequestedInput; @@ -444,7 +446,12 @@ impl AnalyticsEventsClient { }); } - pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { + pub fn track_app_used( + &self, + tracking: TrackEventsContext, + app: AppInvocation, + elicitation_type: Option, + ) { let Some(queue) = self.queue.as_ref() else { return; }; @@ -452,10 +459,20 @@ impl AnalyticsEventsClient { return; } self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed( - AppUsedInput { tracking, app }, + AppUsedInput { + tracking, + app, + elicitation_type, + }, ))); } + pub fn track_mcp_tool_call_elicitation(&self, input: McpToolCallElicitation) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::McpToolCallElicitation(input), + )); + } + pub fn track_hook_run(&self, tracking: TrackEventsContext, hook: HookRunFact) { self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::HookRun( HookRunInput { tracking, hook }, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index b16700c8a4..5cc40f4f72 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -49,15 +49,16 @@ use crate::events::TrackEventRequest; #[cfg(debug_assertions)] use crate::events::codex_artifact_operation_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AppInvocation; #[cfg(debug_assertions)] use crate::facts::ArtifactOperation; #[cfg(debug_assertions)] use crate::facts::ArtifactOperationLifecycle; use crate::facts::CustomAnalyticsFact; +use crate::facts::ElicitationType; use crate::facts::InvocationType; use crate::facts::PluginMeasurementRow; use crate::facts::PluginMeasurementsInput; -#[cfg(debug_assertions)] use crate::facts::TrackEventsContext; use crate::reducer::MAX_PLUGIN_MEASUREMENTS_PER_BATCH; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; @@ -235,6 +236,7 @@ fn sample_mcp_tool_call_event(thread_id: &str, plugin_id: Option<&str>) -> Track mcp_error_present: false, plugin_id: plugin_id.map(str::to_string), connector_id: None, + elicitation_type: None, }, }) } @@ -913,6 +915,55 @@ async fn flush_is_noop_when_analytics_is_disabled() { client.flush().await; } +#[test] +fn app_used_preserves_first_classification_and_emits_again_next_turn() { + let (client, mut receiver) = client_with_receiver(); + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + product_client_id: "codex_desktop".to_string(), + }; + for (turn_id, elicitation_type) in [ + ("turn-1", Some(ElicitationType::AuthOrLink)), + ("turn-1", None), + ("turn-2", None), + ] { + client.track_app_used( + TrackEventsContext { + turn_id: turn_id.to_string(), + ..tracking.clone() + }, + AppInvocation { + connector_id: Some("calendar".to_string()), + app_name: Some("Calendar".to_string()), + invocation_type: Some(InvocationType::Implicit), + }, + elicitation_type, + ); + } + for (turn_id, elicitation_type) in [ + ("turn-1", Some(ElicitationType::AuthOrLink)), + ("turn-2", None), + ] { + let Ok(AnalyticsEventsQueueMessage::Fact(input)) = receiver.try_recv() else { + panic!("expected app-used analytics fact"); + }; + let AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed(input)) = *input else { + panic!("expected app-used analytics fact"); + }; + assert_eq!( + ( + input.tracking.turn_id.as_str(), + input.app.connector_id.as_deref(), + input.elicitation_type, + ), + (turn_id, Some("calendar"), elicitation_type) + ); + } + assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); +} + #[test] fn track_notification_only_enqueues_analytics_relevant_notifications() { let (client, mut receiver) = client_with_receiver(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index b53453a10f..a79a7d1874 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -12,6 +12,7 @@ use crate::facts::CompactionReason; use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; +use crate::facts::ElicitationType; use crate::facts::GoalEventKind; use crate::facts::HookRunFact; use crate::facts::ImagePreparationMetadata; @@ -859,6 +860,7 @@ pub(crate) struct CodexMcpToolCallEventParams { pub(crate) mcp_error_present: bool, pub(crate) plugin_id: Option, pub(crate) connector_id: Option, + pub(crate) elicitation_type: Option, } #[derive(Serialize)] @@ -967,10 +969,17 @@ pub(crate) struct CodexAppMentionedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexAppUsedMetadata { + #[serde(flatten)] + pub(crate) app: CodexAppMetadata, + pub(crate) elicitation_type: Option, +} + #[derive(Serialize)] pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_type: &'static str, - pub(crate) event_params: CodexAppMetadata, + pub(crate) event_params: CodexAppUsedMetadata, } #[derive(Serialize)] diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 73de212d8a..05df3bc44c 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -391,12 +391,33 @@ pub enum InvocationType { Implicit, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ElicitationType { + /// Authentication or account linking blocked the original attempt, as reported + /// by trusted connector auth-failure metadata. Both are treated the same when + /// identifying elicitation-only usage. This does not imply that an + /// authentication prompt was shown or completed. + AuthOrLink, + Approval, +} + pub struct AppInvocation { pub connector_id: Option, pub app_name: Option, pub invocation_type: Option, } +/// A known classification, queued before the corresponding item completion. +/// Ordinary calls do not send this fact; their emitted classification stays null. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct McpToolCallElicitation { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub elicitation_type: ElicitationType, +} + #[derive(Clone)] pub struct SubAgentThreadStartedInput { pub session_id: String, @@ -576,6 +597,7 @@ pub(crate) enum CustomAnalyticsFact { SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), + McpToolCallElicitation(McpToolCallElicitation), HookRun(HookRunInput), PluginUsed(PluginUsedInput), PluginInstallRequested(PluginInstallRequestedInput), @@ -625,6 +647,7 @@ pub(crate) struct AppMentionedInput { pub(crate) struct AppUsedInput { pub tracking: TrackEventsContext, pub app: AppInvocation, + pub elicitation_type: Option, } pub(crate) struct HookRunInput { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d40211327f..f1e4b20547 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -43,6 +43,7 @@ pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; pub use facts::ControlToolCallFact; pub use facts::ControlToolCallStatus; +pub use facts::ElicitationType; pub use facts::ExternalAgentConfigImportCompletedInput; pub use facts::ExternalAgentConfigImportFailureInput; pub use facts::GoalEventKind; @@ -52,6 +53,7 @@ pub use facts::ImagePreparationFact; pub use facts::ImagePreparationMetadata; pub use facts::InputError; pub use facts::InvocationType; +pub use facts::McpToolCallElicitation; pub use facts::PluginInstallRequestSource; pub use facts::PluginInstallRequested; pub use facts::PluginInstallRequestedPlugin; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 974d5d948b..026c47b67f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -6,6 +6,7 @@ use crate::events::AppServerRpcTransport; use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexAppUsedMetadata; use crate::events::CodexCollabAgentToolCallEventParams; use crate::events::CodexCollabAgentToolCallEventRequest; use crate::events::CodexCommandExecutionEventParams; @@ -88,12 +89,14 @@ use crate::facts::CodexGoalEvent; use crate::facts::ControlToolCallFact; use crate::facts::ControlToolCallStatus; use crate::facts::CustomAnalyticsFact; +use crate::facts::ElicitationType; use crate::facts::ExternalAgentConfigImportCompletedInput; use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunInput; use crate::facts::ImagePreparationFact; use crate::facts::ImagePreparationMetadata; use crate::facts::InvocationType; +use crate::facts::McpToolCallElicitation; use crate::facts::PluginInstallFailedInput; use crate::facts::PluginInstallRequestedInput; use crate::facts::PluginMeasurementRow; @@ -210,6 +213,8 @@ pub(crate) struct AnalyticsReducer { code_mode_cells: HashMap>, pending_reviews: HashMap, item_review_summaries: HashMap, + // Bounded to 256 entries, keeping linear lookups small; evict the oldest entry when full. + pending_mcp_tool_elicitations: VecDeque<(ToolItemKey, ElicitationType)>, } struct ConnectionState { @@ -712,6 +717,34 @@ impl AnalyticsReducer { CustomAnalyticsFact::AppUsed(input) => { self.ingest_app_used(input, out); } + CustomAnalyticsFact::McpToolCallElicitation(input) => { + let McpToolCallElicitation { + thread_id, + turn_id, + item_id, + elicitation_type, + } = input; + let key = ToolItemKey { + thread_id, + turn_id, + item_id, + }; + if self + .pending_mcp_tool_elicitations + .iter() + .any(|(pending, _)| pending == &key) + { + return; + } + if self.pending_mcp_tool_elicitations.len() >= MAX_TOOL_RESPONSE_ENTRIES { + self.pending_mcp_tool_elicitations.pop_front(); + tracing::warn!( + "expiring oldest MCP elicitation classification: state is full" + ); + } + self.pending_mcp_tool_elicitations + .push_back((key, elicitation_type)); + } CustomAnalyticsFact::HookRun(input) => { self.ingest_hook_run(input, out); } @@ -1365,8 +1398,15 @@ impl AnalyticsReducer { } fn ingest_app_used(&mut self, input: AppUsedInput, out: &mut Vec) { - let AppUsedInput { tracking, app } = input; - let event_params = codex_app_metadata(&tracking, app); + let AppUsedInput { + tracking, + app, + elicitation_type, + } = input; + let event_params = CodexAppUsedMetadata { + app: codex_app_metadata(&tracking, app), + elicitation_type, + }; out.push(TrackEventRequest::AppUsed(CodexAppUsedEventRequest { event_type: "codex_app_used", event_params, @@ -1922,6 +1962,23 @@ impl AnalyticsReducer { let Some(item_id) = tracked_tool_item_id(¬ification.item) else { return; }; + let key = ToolItemKey { + thread_id: notification.thread_id.clone(), + turn_id: notification.turn_id.clone(), + item_id: item_id.to_string(), + }; + let elicitation_type = + if matches!(notification.item, ThreadItem::McpToolCall { .. }) { + // Producers enqueue known classifications before completion on + // the same analytics queue, so completed keys need no history. + self.pending_mcp_tool_elicitations + .iter() + .position(|(pending, _)| pending == &key) + .and_then(|index| self.pending_mcp_tool_elicitations.remove(index)) + .map(|(_, elicitation_type)| elicitation_type) + } else { + None + }; let Some(turn_state) = self.turns.get_mut(¬ification.turn_id) else { tracing::warn!( thread_id = %notification.thread_id, @@ -1932,11 +1989,6 @@ impl AnalyticsReducer { return; }; turn_state.tool_counts.record(¬ification.item); - let key = ToolItemKey { - thread_id: notification.thread_id.clone(), - turn_id: notification.turn_id.clone(), - item_id: item_id.to_string(), - }; let Some((started_at_ms, model_context)) = self.tool_items_started_at_ms.remove(&key) else { @@ -1968,6 +2020,7 @@ impl AnalyticsReducer { thread_state, thread_metadata, review_summary: self.item_review_summaries.get(&key), + elicitation_type, }) { let root_turn_id = self .turns @@ -2017,6 +2070,8 @@ impl AnalyticsReducer { } }); self.code_mode_cells.remove(¬ification.thread_id); + self.pending_mcp_tool_elicitations + .retain(|(key, _)| key.thread_id != notification.thread_id); } ServerNotification::TurnStarted(notification) => { let turn_state = self.turns.entry(notification.turn.id).or_default(); @@ -2628,6 +2683,7 @@ struct ToolItemEventInput<'a> { thread_state: &'a ThreadAnalyticsState, thread_metadata: &'a ThreadMetadataState, review_summary: Option<&'a ItemReviewSummary>, + elicitation_type: Option, } fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { @@ -2642,6 +2698,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { thread_state, thread_metadata, review_summary, + elicitation_type, } = input; match item { ThreadItem::CommandExecution { @@ -2781,6 +2838,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { connector_id: app_context .as_ref() .map(|app_context| app_context.connector_id.clone()), + elicitation_type, }, }, )) @@ -3730,12 +3788,116 @@ pub(crate) fn normalize_path_for_skill_id( #[cfg(test)] mod tests { use super::*; + use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::JSONRPCErrorError; + use codex_app_server_protocol::ThreadClosedNotification; use codex_protocol::models::SandboxEnforcement; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; + #[tokio::test] + async fn pending_classifications_evict_oldest_and_clean_up() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let keys = (0..=MAX_TOOL_RESPONSE_ENTRIES) + .map(|index| ToolItemKey { + thread_id: if index == MAX_TOOL_RESPONSE_ENTRIES { + "other-thread" + } else { + "thread" + } + .to_string(), + turn_id: "turn".to_string(), + item_id: index.to_string(), + }) + .collect::>(); + let assert_pending = |reducer: &AnalyticsReducer, keys: &[ToolItemKey]| { + let expected = keys + .iter() + .cloned() + .map(|key| (key, ElicitationType::AuthOrLink)) + .collect::>(); + assert!(reducer.pending_mcp_tool_elicitations == expected); + }; + for key in &keys { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::McpToolCallElicitation( + McpToolCallElicitation { + thread_id: key.thread_id.clone(), + turn_id: key.turn_id.clone(), + item_id: key.item_id.clone(), + elicitation_type: ElicitationType::AuthOrLink, + }, + )), + &mut events, + ) + .await; + } + assert_pending(&reducer, &keys[1..]); + + // A duplicate at capacity keeps the first value and original arrival order. + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::McpToolCallElicitation( + McpToolCallElicitation { + thread_id: keys[1].thread_id.clone(), + turn_id: keys[1].turn_id.clone(), + item_id: keys[1].item_id.clone(), + elicitation_type: ElicitationType::Approval, + }, + )), + &mut events, + ) + .await; + assert_pending(&reducer, &keys[1..]); + + // Out-of-order completion consumes its marker even without turn context. + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: keys[2].thread_id.clone(), + turn_id: keys[2].turn_id.clone(), + completed_at_ms: 1_000, + item: ThreadItem::McpToolCall { + id: keys[2].item_id.clone(), + server: "server".to_string(), + tool: "tool".to_string(), + status: McpToolCallStatus::Failed, + arguments: serde_json::json!({}), + app_context: None, + mcp_app_resource_uri: None, + plugin_id: None, + read_only_hint: None, + result: None, + error: None, + duration_ms: None, + }, + }, + ))), + &mut events, + ) + .await; + let mut remaining = keys[1..].to_vec(); + remaining.remove(1); + assert_pending(&reducer, &remaining); + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ThreadClosed( + ThreadClosedNotification { + thread_id: "thread".to_string(), + }, + ))), + &mut events, + ) + .await; + assert_pending(&reducer, &keys[MAX_TOOL_RESPONSE_ENTRIES..]); + assert!(events.is_empty()); + } + #[tokio::test] async fn rejected_turn_interrupt_removes_pending_analytics_request() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 86995eea81..70ae3e65cb 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1090,6 +1090,7 @@ async fn maybe_track_codex_app_used( app_name, invocation_type: Some(invocation_type), }, + /*elicitation_type*/ None, ); } diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index a9125d33f0..a8beb8272d 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -235,6 +235,7 @@ async fn thread_analytics_opt_out_overrides_shared_client() { app_name: None, invocation_type: None, }, + /*elicitation_type*/ None, ); services.analytics_events_client.flush().await; }