mirror of
https://github.com/openai/codex.git
synced 2026-09-10 20:26:47 +00:00
Emit plugin install suggestion outcomes from core
This commit is contained in:
@@ -4,6 +4,10 @@ use std::sync::Arc;
|
||||
use codex_analytics::PluginInstallRequestSource;
|
||||
use codex_analytics::PluginInstallRequested;
|
||||
use codex_analytics::PluginInstallRequestedPlugin;
|
||||
use codex_analytics::PluginInstallSuggestionOutcome;
|
||||
use codex_analytics::PluginInstallSuggestionOutcomeTool;
|
||||
use codex_analytics::PluginInstallSuggestionResponseAction;
|
||||
use codex_analytics::PluginInstallSuggestionToolType;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||
@@ -180,23 +184,24 @@ impl RequestPluginInstallHandler {
|
||||
let tool_type = tool.tool_type();
|
||||
|
||||
let suggestion_id = format!("request_plugin_install_{call_id}");
|
||||
let source = match self.presentation {
|
||||
ToolSuggestPresentation::ListTool => PluginInstallRequestSource::LegacyDiscovery,
|
||||
ToolSuggestPresentation::RecommendationContext => {
|
||||
PluginInstallRequestSource::EndpointRecommendation
|
||||
}
|
||||
};
|
||||
let tracking = build_track_events_context(
|
||||
turn.model_info.slug.clone(),
|
||||
session.thread_id.to_string(),
|
||||
turn.sub_id.clone(),
|
||||
turn.originator.clone(),
|
||||
);
|
||||
if let DiscoverableTool::Plugin(plugin) = &tool {
|
||||
let source = match self.presentation {
|
||||
ToolSuggestPresentation::ListTool => PluginInstallRequestSource::LegacyDiscovery,
|
||||
ToolSuggestPresentation::RecommendationContext => {
|
||||
PluginInstallRequestSource::EndpointRecommendation
|
||||
}
|
||||
};
|
||||
session
|
||||
.services
|
||||
.analytics_events_client
|
||||
.track_plugin_install_requested(
|
||||
build_track_events_context(
|
||||
turn.model_info.slug.clone(),
|
||||
session.thread_id.to_string(),
|
||||
turn.sub_id.clone(),
|
||||
turn.originator.clone(),
|
||||
),
|
||||
tracking.clone(),
|
||||
PluginInstallRequested {
|
||||
suggestion_id: suggestion_id.clone(),
|
||||
plugins: vec![PluginInstallRequestedPlugin {
|
||||
@@ -210,7 +215,7 @@ impl RequestPluginInstallHandler {
|
||||
);
|
||||
}
|
||||
|
||||
let request_id = RequestId::String(suggestion_id.into());
|
||||
let request_id = RequestId::String(suggestion_id.clone().into());
|
||||
let request = build_request_plugin_install_elicitation_request(suggest_reason, &tool);
|
||||
let elicitation = session
|
||||
.request_mcp_server_elicitation(
|
||||
@@ -243,24 +248,64 @@ impl RequestPluginInstallHandler {
|
||||
}
|
||||
|
||||
if elicitation.sent {
|
||||
let tool_type = match tool_type {
|
||||
DiscoverableToolType::Connector => "connector",
|
||||
DiscoverableToolType::Plugin => "plugin",
|
||||
let (outcome_tool_type, tool_type_name) = match tool_type {
|
||||
DiscoverableToolType::Connector => {
|
||||
(PluginInstallSuggestionToolType::Connector, "connector")
|
||||
}
|
||||
DiscoverableToolType::Plugin => (PluginInstallSuggestionToolType::Plugin, "plugin"),
|
||||
};
|
||||
let response_action = match response.as_ref().map(|response| &response.action) {
|
||||
Some(ElicitationAction::Accept) => "accept",
|
||||
Some(ElicitationAction::Decline) => "decline",
|
||||
Some(ElicitationAction::Cancel) => "cancel",
|
||||
None => "unavailable",
|
||||
let (response_action, response_action_name) =
|
||||
match response.as_ref().map(|response| &response.action) {
|
||||
Some(ElicitationAction::Accept) => {
|
||||
(PluginInstallSuggestionResponseAction::Accept, "accept")
|
||||
}
|
||||
Some(ElicitationAction::Decline) => {
|
||||
(PluginInstallSuggestionResponseAction::Decline, "decline")
|
||||
}
|
||||
Some(ElicitationAction::Cancel) => {
|
||||
(PluginInstallSuggestionResponseAction::Cancel, "cancel")
|
||||
}
|
||||
None => (
|
||||
PluginInstallSuggestionResponseAction::Unavailable,
|
||||
"unavailable",
|
||||
),
|
||||
};
|
||||
let (remote_plugin_id, connector_ids) = match &tool {
|
||||
DiscoverableTool::Connector(connector) => (None, vec![connector.id.clone()]),
|
||||
DiscoverableTool::Plugin(plugin) => (
|
||||
plugin.remote_plugin_id.clone(),
|
||||
plugin.app_connector_ids.clone(),
|
||||
),
|
||||
};
|
||||
turn.session_telemetry.record_plugin_install_suggestion(
|
||||
tool_type,
|
||||
tool_type_name,
|
||||
tool.id(),
|
||||
tool.name(),
|
||||
response_action,
|
||||
response_action_name,
|
||||
user_confirmed,
|
||||
completed,
|
||||
);
|
||||
session
|
||||
.services
|
||||
.analytics_events_client
|
||||
.track_plugin_install_suggestion_outcome(
|
||||
tracking,
|
||||
PluginInstallSuggestionOutcome {
|
||||
suggestion_id,
|
||||
source,
|
||||
tools: vec![PluginInstallSuggestionOutcomeTool {
|
||||
tool_type: outcome_tool_type,
|
||||
tool_id: tool.id().to_string(),
|
||||
tool_name: tool.name().to_string(),
|
||||
remote_plugin_id,
|
||||
connector_ids,
|
||||
selected: user_confirmed,
|
||||
}],
|
||||
response_action,
|
||||
user_confirmed,
|
||||
completed,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let content = serde_json::to_string(&RequestPluginInstallResult {
|
||||
|
||||
@@ -199,6 +199,32 @@ async fn resolve_install_elicitation(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_analytics_event(server: &wiremock::MockServer, event_type: &str) -> Value {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let requests = server.received_requests().await.unwrap_or_default();
|
||||
if let Some(event) = requests
|
||||
.into_iter()
|
||||
.filter(|request| request.url.path() == "/codex/analytics-events/events")
|
||||
.find_map(|request| {
|
||||
let payload: Value = serde_json::from_slice(&request.body).ok()?;
|
||||
payload["events"].as_array().and_then(|events| {
|
||||
events
|
||||
.iter()
|
||||
.find(|event| event["event_type"] == event_type)
|
||||
.cloned()
|
||||
})
|
||||
})
|
||||
{
|
||||
return event;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
panic!("timed out waiting for {event_type} analytics");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn mount_remote_calendar_recommendation(server: &wiremock::MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ps/plugins/suggested"))
|
||||
@@ -328,6 +354,80 @@ async fn explicit_false_preserves_legacy_workflow() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn legacy_connector_install_emits_attributed_suggestion_outcome() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let apps_server = AppsTestServer::mount(&server).await?;
|
||||
mount_recommendations(
|
||||
&server,
|
||||
ResponseTemplate::new(200).set_body_json(json!({"enabled": false, "plugins": []})),
|
||||
)
|
||||
.await;
|
||||
let call_id = "install-gmail";
|
||||
let _mock = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
call_id,
|
||||
REQUEST_PLUGIN_INSTALL_TOOL_NAME,
|
||||
&serde_json::to_string(&json!({
|
||||
"tool_type": "connector",
|
||||
"action_type": "install",
|
||||
"tool_id": DISCOVERABLE_GMAIL_ID,
|
||||
"suggest_reason": "Use Gmail for this request"
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let test = build_test(&server, &apps_server).await?;
|
||||
|
||||
let elicitation = start_install_turn(&test, "use Gmail").await?;
|
||||
resolve_install_elicitation(&test, elicitation, ElicitationAction::Decline).await?;
|
||||
|
||||
let outcome_event =
|
||||
wait_for_analytics_event(&server, "codex_plugin_install_suggestion_outcome").await;
|
||||
let thread_id = outcome_event["event_params"]["thread_id"].clone();
|
||||
let turn_id = outcome_event["event_params"]["turn_id"].clone();
|
||||
assert_eq!(
|
||||
outcome_event,
|
||||
json!({
|
||||
"event_type": "codex_plugin_install_suggestion_outcome",
|
||||
"event_params": {
|
||||
"suggestion_id": "request_plugin_install_install-gmail",
|
||||
"source": "legacy_discovery",
|
||||
"tools": [{
|
||||
"tool_type": "connector",
|
||||
"tool_id": DISCOVERABLE_GMAIL_ID,
|
||||
"tool_name": DISCOVERABLE_GMAIL_ID,
|
||||
"remote_plugin_id": null,
|
||||
"connector_ids": [DISCOVERABLE_GMAIL_ID],
|
||||
"selected": false,
|
||||
}],
|
||||
"response_action": "decline",
|
||||
"user_confirmed": false,
|
||||
"completed": false,
|
||||
"thread_id": thread_id,
|
||||
"turn_id": turn_id,
|
||||
"model_slug": "gpt-5.4",
|
||||
"product_client_id": codex_login::default_client::originator().value,
|
||||
}
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn endpoint_mode_injects_candidates_hides_list_and_rejects_invented_ids() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -476,29 +576,7 @@ async fn run_remote_plugin_install_metadata_case() -> Result<()> {
|
||||
assert_eq!(meta["remote_plugin_id"], REMOTE_PLUGIN_ID);
|
||||
assert_eq!(meta["app_connector_ids"], json!([APP_CONNECTOR_ID]));
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let analytics_event = loop {
|
||||
let requests = server.received_requests().await.unwrap_or_default();
|
||||
if let Some(event) = requests
|
||||
.into_iter()
|
||||
.filter(|request| request.url.path() == "/codex/analytics-events/events")
|
||||
.find_map(|request| {
|
||||
let payload: Value = serde_json::from_slice(&request.body).ok()?;
|
||||
payload["events"].as_array().and_then(|events| {
|
||||
events
|
||||
.iter()
|
||||
.find(|event| event["event_type"] == "codex_plugin_install_requested")
|
||||
.cloned()
|
||||
})
|
||||
})
|
||||
{
|
||||
break event;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
panic!("timed out waiting for plugin install request analytics");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
};
|
||||
let analytics_event = wait_for_analytics_event(&server, "codex_plugin_install_requested").await;
|
||||
let thread_id = analytics_event["event_params"]["thread_id"].clone();
|
||||
let turn_id = analytics_event["event_params"]["turn_id"].clone();
|
||||
assert_eq!(
|
||||
@@ -514,8 +592,8 @@ async fn run_remote_plugin_install_metadata_case() -> Result<()> {
|
||||
"connector_ids": [APP_CONNECTOR_ID],
|
||||
}],
|
||||
"source": "endpoint_recommendation",
|
||||
"thread_id": thread_id,
|
||||
"turn_id": turn_id,
|
||||
"thread_id": thread_id.clone(),
|
||||
"turn_id": turn_id.clone(),
|
||||
"model_slug": "gpt-5.4",
|
||||
"product_client_id": codex_login::default_client::originator().value,
|
||||
}
|
||||
@@ -524,6 +602,34 @@ async fn run_remote_plugin_install_metadata_case() -> Result<()> {
|
||||
|
||||
resolve_install_elicitation(&test, elicitation, ElicitationAction::Decline).await?;
|
||||
|
||||
let outcome_event =
|
||||
wait_for_analytics_event(&server, "codex_plugin_install_suggestion_outcome").await;
|
||||
assert_eq!(
|
||||
outcome_event,
|
||||
json!({
|
||||
"event_type": "codex_plugin_install_suggestion_outcome",
|
||||
"event_params": {
|
||||
"suggestion_id": "request_plugin_install_install-github",
|
||||
"source": "endpoint_recommendation",
|
||||
"tools": [{
|
||||
"tool_type": "plugin",
|
||||
"tool_id": "github@openai-curated-remote",
|
||||
"tool_name": "GitHub",
|
||||
"remote_plugin_id": REMOTE_PLUGIN_ID,
|
||||
"connector_ids": [APP_CONNECTOR_ID],
|
||||
"selected": false,
|
||||
}],
|
||||
"response_action": "decline",
|
||||
"user_confirmed": false,
|
||||
"completed": false,
|
||||
"thread_id": thread_id,
|
||||
"turn_id": turn_id,
|
||||
"model_slug": "gpt-5.4",
|
||||
"product_client_id": codex_login::default_client::originator().value,
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
for request in requests {
|
||||
@@ -591,6 +697,37 @@ async fn run_remote_plugin_install_refresh_case(refreshed_tools: RefreshedAppsTo
|
||||
drop(initial_remote_installed_plugins);
|
||||
resolve_install_elicitation(&test, elicitation, ElicitationAction::Accept).await?;
|
||||
|
||||
let completed = matches!(refreshed_tools, RefreshedAppsTools::Available);
|
||||
let outcome_event =
|
||||
wait_for_analytics_event(&server, "codex_plugin_install_suggestion_outcome").await;
|
||||
let thread_id = outcome_event["event_params"]["thread_id"].clone();
|
||||
let turn_id = outcome_event["event_params"]["turn_id"].clone();
|
||||
assert_eq!(
|
||||
outcome_event,
|
||||
json!({
|
||||
"event_type": "codex_plugin_install_suggestion_outcome",
|
||||
"event_params": {
|
||||
"suggestion_id": "request_plugin_install_install-calendar",
|
||||
"source": "endpoint_recommendation",
|
||||
"tools": [{
|
||||
"tool_type": "plugin",
|
||||
"tool_id": REMOTE_CALENDAR_PLUGIN_CONFIG_ID,
|
||||
"tool_name": "Calendar",
|
||||
"remote_plugin_id": REMOTE_CALENDAR_PLUGIN_ID,
|
||||
"connector_ids": [CALENDAR_CONNECTOR_ID],
|
||||
"selected": true,
|
||||
}],
|
||||
"response_action": "accept",
|
||||
"user_confirmed": true,
|
||||
"completed": completed,
|
||||
"thread_id": thread_id,
|
||||
"turn_id": turn_id,
|
||||
"model_slug": "gpt-5.4",
|
||||
"product_client_id": codex_login::default_client::originator().value,
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(
|
||||
@@ -599,7 +736,6 @@ async fn run_remote_plugin_install_refresh_case(refreshed_tools: RefreshedAppsTo
|
||||
.is_none(),
|
||||
"calendar tool should be absent before the remote install"
|
||||
);
|
||||
let completed = matches!(refreshed_tools, RefreshedAppsTools::Available);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(
|
||||
&requests[1]
|
||||
|
||||
Reference in New Issue
Block a user