Expose assistant content token counts in completion notifications (#59041)

## What changed

Add optional `contentTokenCount` metadata to `item/completed` notifications, carrying the producer-supplied token count for each completed assistant message in default and plan modes. Counts include any sampled footer and are `null` when unavailable.

Keep this telemetry separate from stored thread items and omit it from subsequent model requests. Update the protocol schemas and generated TypeScript and Python types.

## Testing

Add coverage for per-message counts, including zero, in default and plan modes; missing and malformed metadata; backward-compatible notification decoding; and exclusion from subsequent model requests.

GitOrigin-RevId: 35973037a42650fa2d7569da8e88cb81215cf971
This commit is contained in:
aojiao-oai
2026-11-03 02:26:29 +00:00
committed by copyberry
parent e839b828fb
commit 16ddd538bd
48 changed files with 443 additions and 39 deletions
@@ -419,13 +419,15 @@ pub fn item_event_to_server_notification(
))
}
EventMsg::ItemCompleted(item_completed_event) => {
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
let mut notification = ItemCompletedNotification::new(
thread_id,
turn_id,
ThreadItemPayload::from(item_completed_event.item),
item_completed_event.started_at_ms,
item_completed_event.completed_at_ms,
))
);
notification.content_token_count = item_completed_event.content_token_count;
ServerNotification::ItemCompleted(notification)
}
EventMsg::PatchApplyUpdated(event) => {
ServerNotification::FileChangePatchUpdated(FileChangePatchUpdatedNotification {
@@ -487,6 +489,56 @@ mod tests {
use codex_protocol::protocol::ExecOutputStream;
use pretty_assertions::assert_eq;
#[test]
fn assistant_completion_count_is_optional_envelope_metadata() {
let item = codex_protocol::items::TurnItem::AgentMessage(
codex_protocol::items::AgentMessageItem {
id: "answer".into(),
content: vec![codex_protocol::items::AgentMessageContent::Text {
text: "Answer".into(),
}],
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
);
let payload = ThreadItemPayload::from(item.clone());
for count in [None, Some(0), Some(23)] {
let event = codex_protocol::protocol::ItemCompletedEvent {
thread_id: ThreadId::new(),
turn_id: "turn".into(),
item: item.clone(),
content_token_count: count,
started_at_ms: Some(1),
completed_at_ms: 2,
};
let mut expected = ItemCompletedNotification::new(
"thread".into(),
"turn".into(),
payload.clone(),
/*started_at_ms*/ Some(1),
/*completed_at_ms*/ 2,
);
expected.content_token_count = count;
assert_item_completed_server_notification(
item_event_to_server_notification(EventMsg::ItemCompleted(event), "thread", "turn"),
expected.clone(),
);
let mut wire = serde_json::to_value(&expected).unwrap();
assert_eq!(
wire.get("contentTokenCount"),
Some(&serde_json::json!(count))
);
wire.as_object_mut().unwrap().remove("contentTokenCount");
expected.content_token_count = None;
assert_eq!(
serde_json::from_value::<ItemCompletedNotification>(wire).unwrap(),
expected,
);
}
}
fn assert_item_started_server_notification(
notification: ServerNotification,
expected: ItemStartedNotification,
@@ -2135,6 +2135,7 @@ mod tests {
};
builder.handle_event_for_history_mode(&EventMsg::ItemStarted(started), mode);
let completed = ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::default(),
turn_id: turn_id.clone(),
item,
@@ -2336,6 +2337,7 @@ mod tests {
});
let events = vec![
EventMsg::ItemCompleted(ItemCompletedEvent {
content_token_count: None,
thread_id,
turn_id: "turn-1".into(),
item: entered,
@@ -2343,6 +2345,7 @@ mod tests {
completed_at_ms: 0,
}),
EventMsg::ItemCompleted(ItemCompletedEvent {
content_token_count: None,
thread_id,
turn_id: "turn-1".into(),
item: exited,
@@ -2594,6 +2597,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::ItemCompleted(ItemCompletedEvent {
content_token_count: None,
thread_id,
turn_id: turn_id.to_string(),
item: sleep_item,
@@ -2679,6 +2683,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::ItemCompleted(ItemCompletedEvent {
content_token_count: None,
thread_id,
turn_id: turn_id.to_string(),
item: CoreTurnItem::Extension(CoreExtensionItem::ImageGeneration(
@@ -2797,6 +2802,7 @@ mod tests {
interaction_input: None,
}),
EventMsg::ItemCompleted(ItemCompletedEvent {
content_token_count: None,
thread_id,
turn_id: turn_id.to_string(),
item: command_item,
@@ -4073,6 +4079,7 @@ mod tests {
use codex_protocol::protocol::HasLegacyEvent;
let completion = ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::new(),
turn_id: "turn-a".into(),
started_at_ms: None,
@@ -4388,6 +4395,7 @@ mod tests {
("c", "latest"),
] {
builder.handle_live_paginated_event(&EventMsg::ItemCompleted(ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::default(),
turn_id: turn_id.clone(),
item: CoreTurnItem::AgentMessage(codex_protocol::items::AgentMessageItem {
@@ -5530,6 +5538,7 @@ mod tests {
}));
let changes =
builder.handle_event_with_changes(&EventMsg::ItemCompleted(ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::new(),
turn_id: "turn-a".into(),
item: hook_prompt,
@@ -5551,6 +5560,7 @@ mod tests {
.join("worker")
.expect("worker path");
let spawn = ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::new(),
turn_id: "turn-a".into(),
item: CoreTurnItem::SubAgentActivity(CoreSubAgentActivityItem {
@@ -5601,6 +5611,7 @@ mod tests {
time_to_first_token_ms: None,
})),
RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::new(),
turn_id: "turn-a".into(),
item: CoreTurnItem::SubAgentActivity(CoreSubAgentActivityItem {
@@ -178,6 +178,7 @@ fn projects_optional_completed_item_lifecycle_timestamps() {
{
let changes = project(RolloutItem::EventMsg(EventMsg::ItemCompleted(
ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::default(),
turn_id: "turn-1".to_string(),
item: item.clone(),
@@ -283,6 +284,7 @@ fn project(item: RolloutItem) -> ThreadHistoryChangeSet {
fn item_completed(thread_id: ThreadId, turn_id: &str, item: TurnItem) -> RolloutItem {
RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
content_token_count: None,
thread_id,
turn_id: turn_id.to_string(),
item,
@@ -1505,6 +1505,12 @@ pub struct ItemCompletedNotification {
pub item: ThreadItem,
pub thread_id: String,
pub turn_id: String,
/// Native content tokens for the completed assistant message, including a sampled footer.
/// Null when unavailable. Completion telemetry is not part of the stored thread item.
#[serde(default)]
#[ts(optional)]
#[ts(type = "number | null")]
pub content_token_count: Option<u64>,
/// Unix timestamp (in milliseconds) when this item lifecycle completed.
#[ts(type = "number")]
pub completed_at_ms: i64,
@@ -1520,6 +1526,7 @@ impl ItemCompletedNotification {
Self {
thread_id,
turn_id,
content_token_count: None,
completed_at_ms,
item: ThreadItem::completed(payload, started_at_ms, completed_at_ms),
}