[codex-analytics] add item lifecycle timing (#20514)

## Why

Tool families already disagree on what their existing `duration` fields
mean, so lifecycle latency should live on the shared item envelope
instead of being inferred from per-tool execution fields. Carrying that
envelope through app-server notifications gives downstream consumers one
reusable timing signal without pretending every tool has the same
execution semantics.

## What changed

- Adds `started_at_ms` to core `ItemStartedEvent` values and
`completed_at_ms` to core `ItemCompletedEvent` values.
- Populates those timestamps in the shared session lifecycle emitters,
so protocol-native items get timing without each producer tracking its
own clock state.
- Exposes `startedAtMs` on app-server `item/started` notifications and
`completedAtMs` on `item/completed` notifications.
- Maps the lifecycle timestamps through the app-server boundary while
leaving legacy-converted notifications nullable when no lifecycle
timestamp exists.
- Regenerates the app-server JSON schema and TypeScript fixtures for the
notification-envelope change and updates downstream fixtures that
construct those notifications directly.
- Extends the existing web-search and image-generation integration flows
to assert the new lifecycle timestamps on the native item events.

## Verification

- `cargo check -p codex-protocol -p codex-core -p
codex-app-server-protocol -p codex-app-server -p codex-tui -p codex-exec
-p codex-app-server-client`
- `cargo test -p codex-core --test all web_search_item_is_emitted`
- `cargo test -p codex-core --test all
image_generation_call_event_is_emitted`
- `cargo test -p codex-app-server-protocol`

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/20514).
* #18748
* #18747
* #17090
* #17089
* __->__ #20514
This commit is contained in:
rhan-oai
2026-05-04 22:33:20 +00:00
committed by GitHub
parent e7e6267ab3
commit aee1fe2659
37 changed files with 332 additions and 11 deletions
+2
View File
@@ -23,6 +23,8 @@ pub struct DynamicToolCallRequest {
pub call_id: String,
pub turn_id: String,
#[serde(default)]
pub started_at_ms: i64,
#[serde(default)]
pub namespace: Option<String>,
pub tool: String,
pub arguments: JsonValue,
+72
View File
@@ -1828,6 +1828,7 @@ pub struct ItemStartedEvent {
pub thread_id: ThreadId,
pub turn_id: String,
pub item: TurnItem,
pub started_at_ms: i64,
}
impl HasLegacyEvent for ItemStartedEvent {
@@ -1854,6 +1855,15 @@ pub struct ItemCompletedEvent {
pub thread_id: ThreadId,
pub turn_id: String,
pub item: TurnItem,
// Old rollout files may contain ItemCompleted events for PlanItem without
// this field. Default to 0 so those persisted rollouts still deserialize
// after tightening the core event contract.
#[serde(default = "default_item_completed_at_ms")]
pub completed_at_ms: i64,
}
const fn default_item_completed_at_ms() -> i64 {
0
}
pub trait HasLegacyEvent {
@@ -2348,6 +2358,8 @@ pub struct DynamicToolCallResponseEvent {
pub call_id: String,
/// Turn ID that this dynamic tool call belongs to.
pub turn_id: String,
#[serde(default)]
pub completed_at_ms: i64,
/// Dynamic tool namespace, when one was provided.
#[serde(default)]
pub namespace: Option<String>,
@@ -3058,6 +3070,8 @@ pub struct ExecCommandBeginEvent {
pub process_id: Option<String>,
/// Turn ID that this command belongs to.
pub turn_id: String,
#[serde(default)]
pub started_at_ms: i64,
/// The command to be executed.
pub command: Vec<String>,
/// The command's working directory if not the default cwd for the agent.
@@ -3082,6 +3096,8 @@ pub struct ExecCommandEndEvent {
pub process_id: Option<String>,
/// Turn ID that this command belongs to.
pub turn_id: String,
#[serde(default)]
pub completed_at_ms: i64,
/// The command that was executed.
pub command: Vec<String>,
/// The command's working directory if not the default cwd for the agent.
@@ -3750,6 +3766,8 @@ pub enum TurnAbortReason {
pub struct CollabAgentSpawnBeginEvent {
/// Identifier for the collab tool call.
pub call_id: String,
#[serde(default)]
pub started_at_ms: i64,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the
@@ -3789,6 +3807,8 @@ pub struct CollabAgentStatusEntry {
pub struct CollabAgentSpawnEndEvent {
/// Identifier for the collab tool call.
pub call_id: String,
#[serde(default)]
pub completed_at_ms: i64,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the newly spawned agent, if it was created.
@@ -3814,6 +3834,8 @@ pub struct CollabAgentSpawnEndEvent {
pub struct CollabAgentInteractionBeginEvent {
/// Identifier for the collab tool call.
pub call_id: String,
#[serde(default)]
pub started_at_ms: i64,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the receiver.
@@ -3827,6 +3849,8 @@ pub struct CollabAgentInteractionBeginEvent {
pub struct CollabAgentInteractionEndEvent {
/// Identifier for the collab tool call.
pub call_id: String,
#[serde(default)]
pub completed_at_ms: i64,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the receiver.
@@ -3846,6 +3870,8 @@ pub struct CollabAgentInteractionEndEvent {
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct CollabWaitingBeginEvent {
#[serde(default)]
pub started_at_ms: i64,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the receivers.
@@ -3863,6 +3889,8 @@ pub struct CollabWaitingEndEvent {
pub sender_thread_id: ThreadId,
/// ID of the waiting call.
pub call_id: String,
#[serde(default)]
pub completed_at_ms: i64,
/// Optional receiver metadata paired with final statuses.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agent_statuses: Vec<CollabAgentStatusEntry>,
@@ -3874,6 +3902,8 @@ pub struct CollabWaitingEndEvent {
pub struct CollabCloseBeginEvent {
/// Identifier for the collab tool call.
pub call_id: String,
#[serde(default)]
pub started_at_ms: i64,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the receiver.
@@ -3884,6 +3914,8 @@ pub struct CollabCloseBeginEvent {
pub struct CollabCloseEndEvent {
/// Identifier for the collab tool call.
pub call_id: String,
#[serde(default)]
pub completed_at_ms: i64,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the receiver.
@@ -3903,6 +3935,8 @@ pub struct CollabCloseEndEvent {
pub struct CollabResumeBeginEvent {
/// Identifier for the collab tool call.
pub call_id: String,
#[serde(default)]
pub started_at_ms: i64,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the receiver.
@@ -3919,6 +3953,8 @@ pub struct CollabResumeBeginEvent {
pub struct CollabResumeEndEvent {
/// Identifier for the collab tool call.
pub call_id: String,
#[serde(default)]
pub completed_at_ms: i64,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the receiver.
@@ -4596,6 +4632,7 @@ mod tests {
queries: None,
},
}),
started_at_ms: 0,
};
let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false);
@@ -4612,6 +4649,7 @@ mod tests {
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
item: TurnItem::UserMessage(UserMessageItem::new(&[])),
started_at_ms: 0,
};
assert!(
@@ -4633,6 +4671,7 @@ mod tests {
result: String::new(),
saved_path: None,
}),
started_at_ms: 0,
};
let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false);
@@ -4648,6 +4687,7 @@ mod tests {
let event = ItemStartedEvent {
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
started_at_ms: 0,
item: TurnItem::FileChange(FileChangeItem {
id: "patch-1".into(),
changes: [(
@@ -4683,6 +4723,7 @@ mod tests {
let event = ItemStartedEvent {
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
started_at_ms: 0,
item: TurnItem::McpToolCall(McpToolCallItem {
id: "mcp-1".into(),
server: "server".into(),
@@ -4724,6 +4765,7 @@ mod tests {
result: "Zm9v".into(),
saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()),
}),
completed_at_ms: 0,
};
let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false);
@@ -4748,6 +4790,7 @@ mod tests {
let event = ItemCompletedEvent {
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
completed_at_ms: 0,
item: TurnItem::FileChange(FileChangeItem {
id: "patch-1".into(),
changes: [(
@@ -4785,6 +4828,7 @@ mod tests {
let event = ItemCompletedEvent {
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
completed_at_ms: 0,
item: TurnItem::McpToolCall(McpToolCallItem {
id: "mcp-1".into(),
server: "server".into(),
@@ -4821,6 +4865,34 @@ mod tests {
}
}
#[test]
fn item_started_event_requires_started_at_ms() {
let mut value = serde_json::to_value(ItemStartedEvent {
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
item: TurnItem::UserMessage(UserMessageItem::new(&[])),
started_at_ms: 123,
})
.unwrap();
value.as_object_mut().unwrap().remove("started_at_ms");
assert!(serde_json::from_value::<ItemStartedEvent>(value).is_err());
}
#[test]
fn item_completed_event_defaults_missing_completed_at_ms() {
let mut value = serde_json::to_value(ItemCompletedEvent {
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
item: TurnItem::UserMessage(UserMessageItem::new(&[])),
completed_at_ms: 123,
})
.unwrap();
value.as_object_mut().unwrap().remove("completed_at_ms");
let event = serde_json::from_value::<ItemCompletedEvent>(value).unwrap();
assert_eq!(event.completed_at_ms, 0);
}
#[test]
fn rollback_failed_error_does_not_affect_turn_status() {
let event = ErrorEvent {