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
+7
View File
@@ -1009,6 +1009,13 @@ pub struct InternalChatMessageMetadataPassthrough {
#[schemars(skip)]
#[ts(skip)]
pub create_time: Option<serde_json::Number>,
/// Native content-token count for this sampled message, including its sampled footer.
/// Read from the producer only; do not replay telemetry into subsequent model requests.
#[serde_as(deserialize_as = "serde_with::DefaultOnError")]
#[serde(default, skip_serializing)]
#[schemars(skip)]
#[ts(skip)]
pub content_token_count: Option<u64>,
/// Harness-owned classifications aligned with the item's content entries.
#[serde_as(deserialize_as = "serde_with::DefaultOnError")]
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -57,6 +57,41 @@ fn malformed_content_kinds_do_not_prevent_loading_response_item() -> Result<()>
Ok(())
}
#[test]
fn malformed_content_token_counts_do_not_prevent_loading_response_item() -> Result<()> {
for value in [json!(-1), json!(true), json!(1.5), json!("4"), json!(null)] {
let item: ResponseItem = serde_json::from_value(response_item_json(json!({
"content_token_count": value,
})))?;
assert_eq!(
item,
response_item(InternalChatMessageMetadataPassthrough::default())
);
}
Ok(())
}
#[test]
fn content_token_counts_are_not_replayed_into_requests() -> Result<()> {
let item: ResponseItem = serde_json::from_value(response_item_json(json!({
"turn_id": "turn-1", "content_token_count": 23,
})))?;
let ResponseItem::Message {
internal_chat_message_metadata_passthrough: Some(ref metadata),
..
} = item
else {
panic!("expected message metadata");
};
assert_eq!(metadata.content_token_count, Some(23));
let encoded = serde_json::to_value(item)?;
assert_eq!(
encoded["internal_chat_message_metadata_passthrough"],
json!({"turn_id": "turn-1"})
);
Ok(())
}
fn response_item(metadata: InternalChatMessageMetadataPassthrough) -> ResponseItem {
ResponseItem::Message {
status: None,
+13
View File
@@ -2194,6 +2194,11 @@ pub struct ItemCompletedEvent {
pub thread_id: ThreadId,
pub turn_id: String,
pub item: TurnItem,
/// Native tokens in the completed assistant message, including any sampled footer.
/// Completion telemetry only; absent for items without a producer-supplied count.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub content_token_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub started_at_ms: Option<i64>,
@@ -5763,6 +5768,7 @@ mod tests {
#[test]
fn item_completed_event_from_image_generation_emits_end_event() {
let event = ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
item: TurnItem::ImageGeneration(ImageGenerationItem {
@@ -5801,6 +5807,7 @@ mod tests {
#[test]
fn item_completed_event_from_file_change_emits_patch_end_event() {
let event = ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
started_at_ms: Some(0),
@@ -5840,6 +5847,7 @@ mod tests {
#[test]
fn item_completed_event_from_mcp_tool_call_emits_end_event() {
let event = ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
started_at_ms: Some(0),
@@ -5921,6 +5929,7 @@ mod tests {
}),
};
let completed = ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
started_at_ms: Some(10),
@@ -5999,6 +6008,7 @@ mod tests {
}),
};
let completed = ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
started_at_ms: Some(10),
@@ -6044,6 +6054,7 @@ mod tests {
#[test]
fn review_mode_item_completion_emits_legacy_events_with_ids() {
let entered = ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
started_at_ms: Some(0),
@@ -6057,6 +6068,7 @@ mod tests {
}),
};
let exited = ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
started_at_ms: Some(0),
@@ -6117,6 +6129,7 @@ mod tests {
#[test]
fn item_completed_event_defaults_missing_completed_at_ms() {
let mut value = serde_json::to_value(ItemCompletedEvent {
content_token_count: None,
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
item: TurnItem::UserMessage(UserMessageItem::new(&[])),