Compare commits

..

1 Commits

Author SHA1 Message Date
rhan-oai
9b94ed7395 [codex-analytics] add steering metadata 2026-04-05 16:08:29 -07:00
8 changed files with 525 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::reducer::AnalyticsReducer;
use crate::reducer::normalize_path_for_skill_id;
@@ -1079,7 +1085,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,
@@ -1125,7 +1131,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,
@@ -1147,6 +1153,90 @@ 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(
&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,
json!({
"event_type": "codex_turn_steer_event",
"event_params": {
"thread_id": "thread-2",
"expected_turn_id": "turn-2",
"accepted_turn_id": "turn-2",
"product_client_id": originator().value,
"num_input_images": 2,
"result": "accepted",
"rejection_reason": null,
"created_at": 1_716_000_123
}
})
);
}
#[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(
&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,
json!({
"event_type": "codex_turn_steer_event",
"event_params": {
"thread_id": "thread-3",
"expected_turn_id": "turn-expected",
"accepted_turn_id": null,
"product_client_id": originator().value,
"num_input_images": 1,
"result": "rejected",
"rejection_reason": "expected_turn_mismatch",
"created_at": 1_716_000_124
}
})
);
}
#[tokio::test]
async fn turn_lifecycle_emits_turn_event() {
let mut reducer = AnalyticsReducer::default();
@@ -1184,6 +1274,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"]["created_at"], json!(455));
assert_eq!(payload["event_params"]["completed_at"], json!(456));
assert_eq!(payload["event_params"]["duration_ms"], json!(1234));
@@ -1197,6 +1288,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();

View File

@@ -6,6 +6,7 @@ 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::PluginState;
use crate::facts::PluginStateChangedInput;
@@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput;
use crate::facts::SubAgentThreadStartedInput;
use crate::facts::TrackEventsContext;
use crate::facts::TurnResolvedConfigFact;
use crate::facts::TurnSteerInput;
use crate::reducer::AnalyticsReducer;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ClientResponse;
@@ -196,6 +198,15 @@ impl AnalyticsEventsClient {
));
}
pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) {
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(
TurnSteerInput {
tracking,
turn_steer,
},
)));
}
pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) {
self.record_fact(AnalyticsFact::Custom(
CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput {

View File

@@ -1,9 +1,12 @@
use crate::facts::AppInvocation;
use crate::facts::CodexTurnSteerEvent;
use crate::facts::InvocationType;
use crate::facts::PluginState;
use crate::facts::SubAgentThreadStartedInput;
use crate::facts::TrackEventsContext;
use crate::facts::TurnStatus;
use crate::facts::TurnSteerRejectionReason;
use crate::facts::TurnSteerResult;
use crate::facts::TurnSubmissionType;
use codex_app_server_protocol::CodexErrorInfo;
use codex_login::default_client::originator;
@@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest {
AppMentioned(CodexAppMentionedEventRequest),
AppUsed(CodexAppUsedEventRequest),
TurnEvent(Box<CodexTurnEventRequest>),
TurnSteer(CodexTurnSteerEventRequest),
PluginUsed(CodexPluginUsedEventRequest),
PluginInstalled(CodexPluginEventRequest),
PluginUninstalled(CodexPluginEventRequest),
@@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest {
pub(crate) event_params: CodexTurnEventParams,
}
#[derive(Serialize)]
pub(crate) struct CodexTurnSteerEventParams {
pub(crate) thread_id: String,
pub(crate) expected_turn_id: Option<String>,
pub(crate) accepted_turn_id: Option<String>,
pub(crate) product_client_id: String,
pub(crate) num_input_images: usize,
pub(crate) result: TurnSteerResult,
pub(crate) rejection_reason: Option<TurnSteerRejectionReason>,
pub(crate) created_at: u64,
}
#[derive(Serialize)]
pub(crate) struct CodexTurnSteerEventRequest {
pub(crate) event_type: &'static str,
pub(crate) event_params: CodexTurnSteerEventParams,
}
#[derive(Serialize)]
pub(crate) struct CodexPluginMetadata {
pub(crate) plugin_id: Option<String>,
@@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata(
}
}
pub(crate) fn codex_turn_steer_event_params(
tracking: &TrackEventsContext,
turn_steer: CodexTurnSteerEvent,
) -> CodexTurnSteerEventParams {
CodexTurnSteerEventParams {
thread_id: tracking.thread_id.clone(),
expected_turn_id: turn_steer.expected_turn_id,
accepted_turn_id: turn_steer.accepted_turn_id,
product_client_id: originator().value,
num_input_images: turn_steer.num_input_images,
result: turn_steer.result,
rejection_reason: turn_steer.rejection_reason,
created_at: turn_steer.created_at,
}
}
pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> {
match thread_source {
SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"),

View File

@@ -73,6 +73,33 @@ pub enum TurnStatus {
Interrupted,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TurnSteerResult {
Accepted,
Rejected,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TurnSteerRejectionReason {
NoActiveTurn,
ExpectedTurnMismatch,
NonSteerableReview,
NonSteerableCompact,
EmptyInput,
}
#[derive(Clone)]
pub struct CodexTurnSteerEvent {
pub expected_turn_id: Option<String>,
pub accepted_turn_id: Option<String>,
pub num_input_images: usize,
pub result: TurnSteerResult,
pub rejection_reason: Option<TurnSteerRejectionReason>,
pub created_at: u64,
}
#[derive(Clone, Debug)]
pub struct SkillInvocation {
pub skill_name: String,
@@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact {
pub(crate) enum CustomAnalyticsFact {
SubAgentThreadStarted(SubAgentThreadStartedInput),
TurnResolvedConfig(Box<TurnResolvedConfigFact>),
TurnSteer(TurnSteerInput),
SkillInvoked(SkillInvokedInput),
AppMentioned(AppMentionedInput),
AppUsed(AppUsedInput),
@@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact {
PluginStateChanged(PluginStateChangedInput),
}
pub(crate) struct TurnSteerInput {
pub tracking: TrackEventsContext,
pub turn_steer: CodexTurnSteerEvent,
}
pub(crate) struct SkillInvokedInput {
pub tracking: TrackEventsContext,
pub invocations: Vec<SkillInvocation>,

View File

@@ -6,12 +6,15 @@ mod reducer;
pub use client::AnalyticsEventsClient;
pub use events::AppServerRpcTransport;
pub use facts::AppInvocation;
pub use facts::CodexTurnSteerEvent;
pub use facts::InvocationType;
pub use facts::SkillInvocation;
pub use facts::SubAgentThreadStartedInput;
pub use facts::TrackEventsContext;
pub use facts::TurnResolvedConfigFact;
pub use facts::TurnStatus;
pub use facts::TurnSteerRejectionReason;
pub use facts::TurnSteerResult;
pub use facts::TurnSubmissionType;
pub use facts::build_track_events_context;

View File

@@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest;
use crate::events::CodexRuntimeMetadata;
use crate::events::CodexTurnEventParams;
use crate::events::CodexTurnEventRequest;
use crate::events::CodexTurnSteerEventRequest;
use crate::events::SkillInvocationEventParams;
use crate::events::SkillInvocationEventRequest;
use crate::events::ThreadInitializationMode;
@@ -16,6 +17,7 @@ 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::plugin_state_event_type;
use crate::events::subagent_thread_started_event_request;
use crate::events::thread_source_name;
@@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput;
use crate::facts::SubAgentThreadStartedInput;
use crate::facts::TurnResolvedConfigFact;
use crate::facts::TurnStatus;
use crate::facts::TurnSteerInput;
use crate::facts::TurnSteerResult;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ClientResponse;
use codex_app_server_protocol::CodexErrorInfo;
@@ -88,6 +92,7 @@ struct TurnState {
created_at: Option<u64>,
token_usage: Option<TokenUsageBreakdown>,
completed: Option<CompletedTurnState>,
steer_count: usize,
}
impl AnalyticsReducer {
@@ -131,6 +136,9 @@ impl AnalyticsReducer {
CustomAnalyticsFact::TurnResolvedConfig(input) => {
self.ingest_turn_resolved_config(*input, out);
}
CustomAnalyticsFact::TurnSteer(input) => {
self.ingest_turn_steer(input, out);
}
CustomAnalyticsFact::SkillInvoked(input) => {
self.ingest_skill_invoked(input, out).await;
}
@@ -225,6 +233,7 @@ impl AnalyticsReducer {
created_at: None,
token_usage: None,
completed: None,
steer_count: 0,
});
turn_state.thread_id = Some(thread_id);
turn_state.num_input_images = Some(num_input_images);
@@ -378,6 +387,7 @@ impl AnalyticsReducer {
created_at: None,
token_usage: None,
completed: None,
steer_count: 0,
});
turn_state.connection_id = Some(connection_id);
turn_state.thread_id = Some(pending_request.thread_id);
@@ -403,6 +413,7 @@ impl AnalyticsReducer {
created_at: None,
token_usage: None,
completed: None,
steer_count: 0,
});
turn_state.created_at = notification
.turn
@@ -418,6 +429,7 @@ impl AnalyticsReducer {
created_at: None,
token_usage: None,
completed: None,
steer_count: 0,
});
turn_state.token_usage = Some(notification.token_usage.last);
}
@@ -433,6 +445,7 @@ impl AnalyticsReducer {
created_at: None,
token_usage: None,
completed: None,
steer_count: 0,
});
turn_state.completed = Some(CompletedTurnState {
status: analytics_turn_status(notification.turn.status),
@@ -488,6 +501,23 @@ impl AnalyticsReducer {
));
}
fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec<TrackEventRequest>) {
let TurnSteerInput {
tracking,
turn_steer,
} = input;
if matches!(turn_steer.result, TurnSteerResult::Accepted)
&& let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref()
&& let Some(turn_state) = self.turns.get_mut(accepted_turn_id)
{
turn_state.steer_count += 1;
}
out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest {
event_type: "codex_turn_steer_event",
event_params: codex_turn_steer_event_params(&tracking, turn_steer),
}));
}
fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec<TrackEventRequest>) {
let Some(turn_state) = self.turns.get(turn_id) else {
return;
@@ -573,7 +603,7 @@ fn codex_turn_event_params(
is_first_turn,
status: completed.status,
turn_error: completed.turn_error,
steer_count: None,
steer_count: Some(turn_state.steer_count),
total_tool_call_count: None,
shell_command_count: None,
file_change_count: None,

View File

@@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_shell_command_sse_response;
use app_test_support::to_response;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE;
use codex_app_server::INVALID_PARAMS_ERROR_CODE;
use codex_app_server_protocol::JSONRPCError;
@@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use tempfile::TempDir;
use tokio::time::timeout;
use super::analytics::enable_analytics_capture;
use super::analytics::wait_for_analytics_event;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
@@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> {
std::fs::create_dir(&codex_home)?;
let server = create_mock_responses_server_sequence(vec![]).await;
create_config_toml(&codex_home, &server.uri())?;
write_mock_responses_config_toml_with_chatgpt_base_url(
&codex_home,
&server.uri(),
&server.uri(),
)?;
enable_analytics_capture(&server, &codex_home).await?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> {
let steer_req = mcp
.send_turn_steer_request(TurnSteerParams {
thread_id: thread.id,
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
@@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> {
.await??;
assert_eq!(steer_err.error.code, -32600);
let event =
wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?;
assert_eq!(event["event_params"]["thread_id"], thread.id);
assert_eq!(event["event_params"]["result"], "rejected");
assert_eq!(event["event_params"]["num_input_images"], 0);
assert_eq!(
event["event_params"]["expected_turn_id"],
"turn-does-not-exist"
);
assert_eq!(
event["event_params"]["accepted_turn_id"],
serde_json::Value::Null
);
assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn");
Ok(())
}
@@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> {
"call_sleep",
)?])
.await;
create_config_toml(&codex_home, &server.uri())?;
write_mock_responses_config_toml_with_chatgpt_base_url(
&codex_home,
&server.uri(),
&server.uri(),
)?;
enable_analytics_capture(&server, &codex_home).await?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> {
"call_sleep",
)?])
.await;
create_config_toml(&codex_home, &server.uri())?;
write_mock_responses_config_toml_with_chatgpt_base_url(
&codex_home,
&server.uri(),
&server.uri(),
)?;
enable_analytics_capture(&server, &codex_home).await?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> {
let steer: TurnSteerResponse = to_response::<TurnSteerResponse>(steer_resp)?;
assert_eq!(steer.turn_id, turn.id);
let event =
wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?;
assert_eq!(event["event_params"]["thread_id"], thread.id);
assert_eq!(event["event_params"]["result"], "accepted");
assert_eq!(event["event_params"]["num_input_images"], 0);
assert_eq!(event["event_params"]["expected_turn_id"], turn.id);
assert_eq!(event["event_params"]["accepted_turn_id"], turn.id);
assert_eq!(
event["event_params"]["rejection_reason"],
serde_json::Value::Null
);
mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT)
.await?;
Ok(())
}
fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}

View File

@@ -49,9 +49,13 @@ use chrono::Local;
use chrono::Utc;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::AppInvocation;
use codex_analytics::CodexTurnSteerEvent;
use codex_analytics::InvocationType;
use codex_analytics::SubAgentThreadStartedInput;
use codex_analytics::TrackEventsContext;
use codex_analytics::TurnResolvedConfigFact;
use codex_analytics::TurnSteerRejectionReason;
use codex_analytics::TurnSteerResult;
use codex_analytics::TurnSubmissionType;
use codex_analytics::build_track_events_context;
use codex_app_server_protocol::McpServerElicitationRequest;
@@ -240,6 +244,32 @@ impl SteerInputError {
},
}
}
fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason {
match self {
Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn,
Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch,
Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind {
NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview,
NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact,
},
Self::EmptyInput => TurnSteerRejectionReason::EmptyInput,
}
}
}
struct AcceptedSteerInput {
turn_id: String,
tracking: TrackEventsContext,
expected_turn_id: String,
num_input_images: usize,
}
struct RejectedSteerInput {
error: SteerInputError,
tracking: TrackEventsContext,
expected_turn_id: Option<String>,
num_input_images: usize,
}
/// Notes from the previous real user turn.
@@ -4078,47 +4108,163 @@ impl Session {
input: Vec<UserInput>,
expected_turn_id: Option<&str>,
) -> Result<String, SteerInputError> {
if input.is_empty() {
return Err(SteerInputError::EmptyInput);
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
match self
.try_append_input_to_active_turn(input, expected_turn_id)
.await
{
Ok(accepted) => {
self.services.analytics_events_client.track_turn_steer(
accepted.tracking,
CodexTurnSteerEvent {
expected_turn_id: Some(accepted.expected_turn_id),
accepted_turn_id: Some(accepted.turn_id.clone()),
num_input_images: accepted.num_input_images,
result: TurnSteerResult::Accepted,
rejection_reason: None,
created_at,
},
);
Ok(accepted.turn_id)
}
Err(rejected) => {
self.services.analytics_events_client.track_turn_steer(
rejected.tracking,
CodexTurnSteerEvent {
expected_turn_id: rejected.expected_turn_id,
accepted_turn_id: None,
num_input_images: rejected.num_input_images,
result: TurnSteerResult::Rejected,
rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()),
created_at,
},
);
Err(rejected.error)
}
}
}
async fn try_append_input_to_active_turn(
&self,
input: Vec<UserInput>,
expected_turn_id: Option<&str>,
) -> Result<AcceptedSteerInput, RejectedSteerInput> {
let thread_id = self.conversation_id.to_string();
let fallback_tracking = || {
build_track_events_context(
String::new(),
thread_id.clone(),
expected_turn_id.unwrap_or_default().to_string(),
)
};
if input.is_empty() {
return Err(RejectedSteerInput {
error: SteerInputError::EmptyInput,
tracking: fallback_tracking(),
expected_turn_id: expected_turn_id.map(str::to_string),
num_input_images: 0,
});
}
let num_input_images = input
.iter()
.filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }))
.count();
let mut active = self.active_turn.lock().await;
let Some(active_turn) = active.as_mut() else {
return Err(SteerInputError::NoActiveTurn(input));
return Err(RejectedSteerInput {
error: SteerInputError::NoActiveTurn(input),
tracking: fallback_tracking(),
expected_turn_id: expected_turn_id.map(str::to_string),
num_input_images,
});
};
let Some((active_turn_id, _)) = active_turn.tasks.first() else {
return Err(SteerInputError::NoActiveTurn(input));
let Some((active_turn_id, task)) = active_turn.tasks.first() else {
return Err(RejectedSteerInput {
error: SteerInputError::NoActiveTurn(input),
tracking: fallback_tracking(),
expected_turn_id: expected_turn_id.map(str::to_string),
num_input_images,
});
};
let active_turn_id = active_turn_id.clone();
let tracking = build_track_events_context(
task.turn_context.model_info.slug.clone(),
thread_id.clone(),
task.turn_context.sub_id.clone(),
);
if let Some(expected_turn_id) = expected_turn_id
&& expected_turn_id != active_turn_id
{
return Err(SteerInputError::ExpectedTurnMismatch {
expected: expected_turn_id.to_string(),
actual: active_turn_id.clone(),
return Err(RejectedSteerInput {
error: SteerInputError::ExpectedTurnMismatch {
expected: expected_turn_id.to_string(),
actual: active_turn_id,
},
tracking,
expected_turn_id: Some(expected_turn_id.to_string()),
num_input_images,
});
}
match active_turn.tasks.first().map(|(_, task)| task.kind) {
Some(crate::state::TaskKind::Regular) => {}
Some(crate::state::TaskKind::Review) => {
return Err(SteerInputError::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
return Err(RejectedSteerInput {
error: SteerInputError::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
},
tracking,
expected_turn_id: expected_turn_id
.map(str::to_string)
.or(Some(active_turn_id)),
num_input_images,
});
}
Some(crate::state::TaskKind::Compact) => {
return Err(SteerInputError::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
return Err(RejectedSteerInput {
error: SteerInputError::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
},
tracking,
expected_turn_id: expected_turn_id
.map(str::to_string)
.or(Some(active_turn_id)),
num_input_images,
});
}
None => {
return Err(RejectedSteerInput {
error: SteerInputError::NoActiveTurn(input),
tracking: fallback_tracking(),
expected_turn_id: expected_turn_id.map(str::to_string),
num_input_images,
});
}
None => return Err(SteerInputError::NoActiveTurn(input)),
}
let mut turn_state = active_turn.turn_state.lock().await;
turn_state.push_pending_input(input.into());
turn_state.accept_mailbox_delivery_for_current_turn();
Ok(active_turn_id.clone())
drop(turn_state);
let expected_turn_id = expected_turn_id
.map(str::to_string)
.unwrap_or_else(|| active_turn_id.clone());
Ok(AcceptedSteerInput {
turn_id: active_turn_id,
tracking,
expected_turn_id,
num_input_images,
})
}
/// Returns the input if there was no task running to inject into.
@@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span {
/// Operation handlers
mod handlers {
use crate::codex::RejectedSteerInput;
use crate::codex::Session;
use crate::codex::SessionSettingsUpdate;
use crate::codex::SteerInputError;
@@ -4943,11 +5090,14 @@ mod handlers {
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
.await;
match sess
.steer_input(items.clone(), /*expected_turn_id*/ None)
.try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None)
.await
{
Ok(_) => current_context.session_telemetry.user_prompt(&items),
Err(SteerInputError::NoActiveTurn(items)) => {
Err(RejectedSteerInput {
error: SteerInputError::NoActiveTurn(items),
..
}) => {
current_context.session_telemetry.user_prompt(&items);
sess.refresh_mcp_servers_if_requested(&current_context)
.await;
@@ -4958,10 +5108,10 @@ mod handlers {
)
.await;
}
Err(err) => {
Err(RejectedSteerInput { error, .. }) => {
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::Error(err.to_error_event()),
msg: EventMsg::Error(error.to_error_event()),
})
.await;
}