Correlate image requests and explicit interrupts with turns (#36092)

## What changed

- Send the active turn ID in the `x-codex-image-turn-id` header for image generation and edit requests.
- Add `explicit_client_interrupt_requested_at_ms` to turn analytics. Record the earliest explicit interrupt request that receives a successful response, while excluding rejected requests.
- Track errors returned by `turn/interrupt` so pending analytics state is cleaned up correctly.

## Testing

- Cover turn ID headers for image generation and editing.
- Cover accepted, retried, and rejected explicit interrupt requests in analytics tests.

GitOrigin-RevId: 80fe996a161b4952b79c5c38238f75b7661b92af
This commit is contained in:
alicec-oai authored and copyberry committed 2026-07-30 05:53:21 +00:00
1 parent 88ec932e96
commit 6219b7c40f
10 files changed
+365 -17

No files matched your search

@@ -137,6 +137,7 @@ use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnDiffUpdatedNotification;
use codex_app_server_protocol::TurnError as AppServerTurnError;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::TurnStatus as AppServerTurnStatus;
@@ -485,6 +486,10 @@ fn sample_turn_steer_response(turn_id: &str) -> ClientResponsePayload {
})
}
fn sample_turn_interrupt_response() -> ClientResponsePayload {
ClientResponsePayload::TurnInterrupt(TurnInterruptResponse {})
}
fn no_active_turn_steer_error() -> JSONRPCErrorError {
JSONRPCErrorError {
code: -32600,
@@ -4073,6 +4078,7 @@ fn turn_event_serializes_expected_shape() {
num_input_images: 2,
is_first_turn: true,
status: Some(TurnStatus::Completed),
explicit_client_interrupt_requested_at_ms: None,
turn_error: None,
codex_error_kind: None,
codex_error_http_status_code: None,
@@ -4147,6 +4153,7 @@ fn turn_event_serializes_expected_shape() {
"num_input_images": 2,
"is_first_turn": true,
"status": "completed",
"explicit_client_interrupt_requested_at_ms": null,
"turn_error": null,
"codex_error_kind": null,
"codex_error_http_status_code": null,
@@ -4942,7 +4949,7 @@ async fn turn_lifecycle_emits_failed_turn_event() {
}
#[tokio::test]
async fn turn_lifecycle_emits_interrupted_turn_event_without_error() {
async fn rejected_turn_interrupt_does_not_tag_interrupted_turn_event() {
let mut reducer = AnalyticsReducer::default();
let mut out = Vec::new();
@@ -4955,6 +4962,28 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() {
/*include_token_usage*/ false,
)
.await;
reducer
.ingest(
AnalyticsFact::ExplicitClientInterruptRequest {
connection_id: 7,
request_id: RequestId::Integer(4),
turn_id: "turn-2".to_string(),
requested_at_ms: 1716000000123,
},
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::ErrorResponse {
connection_id: 7,
request_id: RequestId::Integer(4),
error: no_active_turn_steer_error(),
error_type: None,
},
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
@@ -4970,10 +4999,148 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() {
assert_eq!(out.len(), 1);
let payload = serde_json::to_value(&out[0]).expect("serialize turn event");
assert_eq!(payload["event_params"]["status"], json!("interrupted"));
assert_eq!(
payload["event_params"]["explicit_client_interrupt_requested_at_ms"],
json!(null)
);
assert_eq!(payload["event_params"]["turn_error"], json!(null));
assert_eq!(payload["event_params"]["codex_error_kind"], json!(null));
}
#[tokio::test]
async fn accepted_turn_interrupt_records_requested_at_on_turn_event() {
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::ExplicitClientInterruptRequest {
connection_id: 7,
request_id: RequestId::Integer(4),
turn_id: "turn-2".to_string(),
requested_at_ms: 1716000000123,
},
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::ClientResponse {
connection_id: 7,
request_id: RequestId::Integer(4),
response: Box::new(sample_turn_interrupt_response()),
thread_originator: None,
},
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
"thread-2",
"turn-2",
AppServerTurnStatus::Interrupted,
/*codex_error_info*/ None,
))),
&mut out,
)
.await;
assert_eq!(out.len(), 1);
let payload = serde_json::to_value(&out[0]).expect("serialize turn event");
assert_eq!(
payload["event_params"]["explicit_client_interrupt_requested_at_ms"],
json!(1716000000123_u64)
);
}
#[tokio::test]
async fn accepted_turn_interrupt_retries_preserve_earliest_requested_at() {
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::ExplicitClientInterruptRequest {
connection_id: 7,
request_id: RequestId::Integer(4),
turn_id: "turn-2".to_string(),
requested_at_ms: 1716000000123,
},
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::ExplicitClientInterruptRequest {
connection_id: 7,
request_id: RequestId::Integer(5),
turn_id: "turn-2".to_string(),
requested_at_ms: 1716000000456,
},
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::ClientResponse {
connection_id: 7,
request_id: RequestId::Integer(5),
response: Box::new(sample_turn_interrupt_response()),
thread_originator: None,
},
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::ClientResponse {
connection_id: 7,
request_id: RequestId::Integer(4),
response: Box::new(sample_turn_interrupt_response()),
thread_originator: None,
},
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
"thread-2",
"turn-2",
AppServerTurnStatus::Interrupted,
/*codex_error_info*/ None,
))),
&mut out,
)
.await;
assert_eq!(out.len(), 1);
let payload = serde_json::to_value(&out[0]).expect("serialize turn event");
assert_eq!(
payload["event_params"]["explicit_client_interrupt_requested_at_ms"],
json!(1716000000123_u64)
);
}
#[tokio::test]
async fn turn_completed_without_started_notification_emits_null_started_at() {
let mut reducer = AnalyticsReducer::default();