[codex-analytics] add steering metadata

This commit is contained in:
rhan-oai
2026-04-07 14:07:21 -07:00
parent e5c3ab9f1c
commit 0c8616c662
8 changed files with 781 additions and 48 deletions

View File

@@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest;
use crate::events::CodexPluginUsedEventRequest;
use crate::events::CodexRuntimeMetadata;
use crate::events::CodexTurnEventRequest;
use crate::events::CodexTurnSteerEventRequest;
use crate::events::ThreadInitializationMode;
use crate::events::ThreadInitializedEvent;
use crate::events::ThreadInitializedEventParams;
@@ -14,11 +15,13 @@ use crate::events::TrackEventRequest;
use crate::events::codex_app_metadata;
use crate::events::codex_plugin_metadata;
use crate::events::codex_plugin_used_metadata;
use crate::events::codex_turn_steer_event_params;
use crate::events::subagent_thread_started_event_request;
use crate::facts::AnalyticsFact;
use crate::facts::AppInvocation;
use crate::facts::AppMentionedInput;
use crate::facts::AppUsedInput;
use crate::facts::CodexTurnSteerEvent;
use crate::facts::CustomAnalyticsFact;
use crate::facts::InvocationType;
use crate::facts::PluginState;
@@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput;
use crate::facts::TrackEventsContext;
use crate::facts::TurnResolvedConfigFact;
use crate::facts::TurnStatus;
use crate::facts::TurnSteerInput;
use crate::facts::TurnSteerRejectionReason;
use crate::facts::TurnSteerResult;
use crate::facts::TurnSubmissionType;
use crate::facts::TurnTokenUsageFact;
use crate::reducer::AnalyticsReducer;
@@ -243,6 +249,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact {
}
}
fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata {
CodexAppServerClientMetadata {
product_client_id: "codex-tui".to_string(),
client_name: Some("codex-tui".to_string()),
client_version: Some("1.0.0".to_string()),
rpc_transport: AppServerRpcTransport::Stdio,
experimental_api_enabled: None,
}
}
fn sample_runtime_metadata() -> CodexRuntimeMetadata {
CodexRuntimeMetadata {
codex_rs_version: "0.1.0".to_string(),
runtime_os: "macos".to_string(),
runtime_os_version: "15.3.1".to_string(),
runtime_arch: "aarch64".to_string(),
}
}
async fn ingest_turn_prerequisites(
reducer: &mut AnalyticsReducer,
out: &mut Vec<TrackEventRequest>,
@@ -1065,7 +1090,7 @@ fn turn_event_serializes_expected_shape() {
is_first_turn: true,
status: Some(TurnStatus::Completed),
turn_error: None,
steer_count: None,
steer_count: Some(0),
total_tool_call_count: None,
shell_command_count: None,
file_change_count: None,
@@ -1111,7 +1136,7 @@ fn turn_event_serializes_expected_shape() {
"is_first_turn": true,
"status": "completed",
"turn_error": null,
"steer_count": null,
"steer_count": 0,
"total_tool_call_count": null,
"shell_command_count": null,
"file_change_count": null,
@@ -1133,6 +1158,280 @@ fn turn_event_serializes_expected_shape() {
);
}
#[test]
fn turn_steer_event_serializes_expected_shape() {
let tracking = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
};
let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest {
event_type: "codex_turn_steer_event",
event_params: codex_turn_steer_event_params(
sample_app_server_client_metadata(),
sample_runtime_metadata(),
&tracking,
CodexTurnSteerEvent {
expected_turn_id: Some("turn-2".to_string()),
accepted_turn_id: Some("turn-2".to_string()),
num_input_images: 2,
result: TurnSteerResult::Accepted,
rejection_reason: None,
created_at: 1_716_000_123,
},
),
});
let payload = serde_json::to_value(&event).expect("serialize turn steer event");
assert_eq!(payload["event_type"], json!("codex_turn_steer_event"));
assert_eq!(payload["event_params"]["thread_id"], json!("thread-2"));
assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2"));
assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2"));
assert_eq!(
payload["event_params"]["app_server_client"],
json!({
"product_client_id": "codex-tui",
"client_name": "codex-tui",
"client_version": "1.0.0",
"rpc_transport": "stdio",
"experimental_api_enabled": null,
})
);
assert_eq!(
payload["event_params"]["runtime"],
json!({
"codex_rs_version": "0.1.0",
"runtime_os": "macos",
"runtime_os_version": "15.3.1",
"runtime_arch": "aarch64",
})
);
assert!(payload["event_params"].get("product_client_id").is_none());
assert_eq!(payload["event_params"]["num_input_images"], json!(2));
assert_eq!(payload["event_params"]["result"], json!("accepted"));
assert_eq!(payload["event_params"]["rejection_reason"], json!(null));
assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_123));
assert!(payload["event_params"].get("created_at").is_none());
}
#[test]
fn rejected_turn_steer_event_serializes_expected_shape() {
let tracking = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-3".to_string(),
turn_id: "turn-3".to_string(),
};
let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest {
event_type: "codex_turn_steer_event",
event_params: codex_turn_steer_event_params(
sample_app_server_client_metadata(),
sample_runtime_metadata(),
&tracking,
CodexTurnSteerEvent {
expected_turn_id: Some("turn-expected".to_string()),
accepted_turn_id: None,
num_input_images: 1,
result: TurnSteerResult::Rejected,
rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch),
created_at: 1_716_000_124,
},
),
});
let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event");
assert_eq!(payload["event_type"], json!("codex_turn_steer_event"));
assert_eq!(payload["event_params"]["thread_id"], json!("thread-3"));
assert_eq!(
payload["event_params"]["expected_turn_id"],
json!("turn-expected")
);
assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null));
assert_eq!(
payload["event_params"]["app_server_client"]["product_client_id"],
json!("codex-tui")
);
assert_eq!(
payload["event_params"]["runtime"]["codex_rs_version"],
json!("0.1.0")
);
assert!(payload["event_params"].get("product_client_id").is_none());
assert_eq!(payload["event_params"]["num_input_images"], json!(1));
assert_eq!(payload["event_params"]["result"], json!("rejected"));
assert_eq!(
payload["event_params"]["rejection_reason"],
json!("expected_turn_mismatch")
);
assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_124));
assert!(payload["event_params"].get("created_at").is_none());
}
#[tokio::test]
async fn turn_steer_uses_connection_metadata() {
let mut reducer = AnalyticsReducer::default();
let mut out = Vec::new();
ingest_turn_prerequisites(
&mut reducer,
&mut out,
/*include_initialize*/ true,
/*include_resolved_config*/ false,
/*include_started*/ false,
/*include_token_usage*/ false,
)
.await;
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput {
tracking: TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
},
turn_steer: CodexTurnSteerEvent {
expected_turn_id: Some("turn-2".to_string()),
accepted_turn_id: Some("turn-2".to_string()),
num_input_images: 2,
result: TurnSteerResult::Accepted,
rejection_reason: None,
created_at: 1_716_000_125,
},
})),
&mut out,
)
.await;
assert_eq!(out.len(), 1);
let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event");
assert_eq!(payload["event_type"], json!("codex_turn_steer_event"));
assert_eq!(
payload["event_params"]["app_server_client"],
json!({
"product_client_id": "codex-tui",
"client_name": "codex-tui",
"client_version": "1.0.0",
"rpc_transport": "stdio",
"experimental_api_enabled": null,
})
);
assert_eq!(
payload["event_params"]["runtime"],
json!({
"codex_rs_version": "0.1.0",
"runtime_os": "macos",
"runtime_os_version": "15.3.1",
"runtime_arch": "aarch64",
})
);
assert!(payload["event_params"].get("product_client_id").is_none());
}
#[tokio::test]
async fn rejected_turn_steer_uses_thread_connection_metadata() {
let mut reducer = AnalyticsReducer::default();
let mut out = Vec::new();
reducer
.ingest(
AnalyticsFact::Initialize {
connection_id: 7,
params: InitializeParams {
client_info: ClientInfo {
name: "codex-tui".to_string(),
title: None,
version: "1.0.0".to_string(),
},
capabilities: None,
},
product_client_id: "codex-tui".to_string(),
runtime: sample_runtime_metadata(),
rpc_transport: AppServerRpcTransport::Stdio,
},
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::Response {
connection_id: 7,
response: Box::new(sample_thread_start_response(
"thread-2", /*ephemeral*/ false, "gpt-5",
)),
},
&mut out,
)
.await;
out.clear();
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput {
tracking: TrackEventsContext {
model_slug: String::new(),
thread_id: "thread-2".to_string(),
turn_id: String::new(),
},
turn_steer: CodexTurnSteerEvent {
expected_turn_id: None,
accepted_turn_id: None,
num_input_images: 1,
result: TurnSteerResult::Rejected,
rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn),
created_at: 1_716_000_126,
},
})),
&mut out,
)
.await;
assert_eq!(out.len(), 1);
let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event");
assert_eq!(payload["event_type"], json!("codex_turn_steer_event"));
assert_eq!(
payload["event_params"]["app_server_client"]["product_client_id"],
json!("codex-tui")
);
assert_eq!(
payload["event_params"]["runtime"]["codex_rs_version"],
json!("0.1.0")
);
assert_eq!(payload["event_params"]["result"], json!("rejected"));
assert_eq!(
payload["event_params"]["rejection_reason"],
json!("no_active_turn")
);
}
#[tokio::test]
async fn turn_steer_does_not_emit_without_connection_metadata() {
let mut reducer = AnalyticsReducer::default();
let mut out = Vec::new();
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput {
tracking: TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
},
turn_steer: CodexTurnSteerEvent {
expected_turn_id: Some("turn-2".to_string()),
accepted_turn_id: None,
num_input_images: 1,
result: TurnSteerResult::Rejected,
rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn),
created_at: 1_716_000_126,
},
})),
&mut out,
)
.await;
assert!(out.is_empty());
}
#[tokio::test]
async fn turn_lifecycle_emits_turn_event() {
let mut reducer = AnalyticsReducer::default();
@@ -1170,6 +1469,7 @@ async fn turn_lifecycle_emits_turn_event() {
);
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));
assert_eq!(payload["event_params"]["started_at"], json!(455));
assert_eq!(payload["event_params"]["completed_at"], json!(456));
assert_eq!(payload["event_params"]["duration_ms"], json!(1234));
@@ -1183,6 +1483,104 @@ async fn turn_lifecycle_emits_turn_event() {
assert_eq!(payload["event_params"]["total_tokens"], json!(321));
}
#[tokio::test]
async fn accepted_steers_increment_turn_steer_count() {
let mut reducer = AnalyticsReducer::default();
let mut out = Vec::new();
ingest_turn_prerequisites(
&mut reducer,
&mut out,
/*include_initialize*/ true,
/*include_resolved_config*/ true,
/*include_started*/ true,
/*include_token_usage*/ false,
)
.await;
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput {
tracking: TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
},
turn_steer: CodexTurnSteerEvent {
expected_turn_id: Some("turn-2".to_string()),
accepted_turn_id: Some("turn-2".to_string()),
num_input_images: 0,
result: TurnSteerResult::Accepted,
rejection_reason: None,
created_at: 1,
},
})),
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput {
tracking: TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
},
turn_steer: CodexTurnSteerEvent {
expected_turn_id: None,
accepted_turn_id: None,
num_input_images: 0,
result: TurnSteerResult::Rejected,
rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn),
created_at: 2,
},
})),
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput {
tracking: TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
},
turn_steer: CodexTurnSteerEvent {
expected_turn_id: Some("turn-2".to_string()),
accepted_turn_id: Some("turn-2".to_string()),
num_input_images: 1,
result: TurnSteerResult::Accepted,
rejection_reason: None,
created_at: 3,
},
})),
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
"thread-2",
"turn-2",
AppServerTurnStatus::Completed,
/*codex_error_info*/ None,
))),
&mut out,
)
.await;
let turn_event = out
.iter()
.find(|event| matches!(event, TrackEventRequest::TurnEvent(_)))
.expect("turn event should be emitted");
let payload = serde_json::to_value(turn_event).expect("serialize turn event");
assert_eq!(payload["event_params"]["steer_count"], json!(2));
}
#[tokio::test]
async fn queued_submission_type_emits_queued_turn_event() {
let mut reducer = AnalyticsReducer::default();