diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820d..f7f24eacd 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03..116844171 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -3553,6 +3569,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a58990303..63d1ec590 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -14350,6 +14366,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f041f8aae..96bdfd41e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -12205,6 +12221,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2e0c3605e..4bdeebf57 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 88448a165..a79ac103c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f26bd03a3..e8211b252 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 88c8e688d..08c4696bb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 845320738..f6d67d5c0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e21f253b7..169743504 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d719ba7d8..e664490df 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 27a8cdd6b..98e022c75 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index c202363e3..f640c4096 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 542aea176..1c10ea3b8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 770cc920c..fc1deb675 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7f1c3e494..5b79f4df1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 761ddc9a6..ccbe93e9c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccb..074ac215f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 99a8f6e62..10d55bb23 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -864,22 +864,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -888,15 +895,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -905,7 +915,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -915,13 +925,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -954,7 +968,7 @@ impl ThreadHistoryBuilder { if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { return; } - self.turns.push(turn.into()); + self.turns.push(Turn::from(turn)); } } @@ -964,6 +978,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1082,6 +1099,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1102,6 +1122,11 @@ impl PendingTurn { self.status = status; self } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -1111,6 +1136,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1122,6 +1150,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1273,6 +1304,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1293,6 +1325,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1345,6 +1379,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1364,6 +1399,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1375,6 +1412,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1464,6 +1504,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1661,6 +1703,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1679,6 +1722,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1715,6 +1760,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1820,6 +1866,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1879,6 +1926,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1966,6 +2014,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2038,6 +2087,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2096,6 +2146,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2108,9 +2159,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2142,6 +2196,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2179,6 +2235,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2191,9 +2248,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2225,6 +2285,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2257,6 +2319,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2320,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2382,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2394,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2409,6 +2477,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2418,6 +2488,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2437,6 +2509,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2449,9 +2522,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2464,6 +2540,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2489,6 +2567,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2499,6 +2578,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2509,6 +2590,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2726,6 +2810,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2738,6 +2823,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2757,6 +2844,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2773,6 +2863,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2791,6 +2882,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2826,6 +2919,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2839,6 +2933,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2869,6 +2965,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2884,6 +2981,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index cdc78647a..7cefde0f2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3693,6 +3693,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f883d078e..141ee78bd 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -127,6 +127,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -190,6 +192,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -201,14 +206,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1704,7 +1716,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1866,11 +1885,18 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1878,8 +1904,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2073,6 +2102,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2083,22 +2113,40 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: turn_summary.started_at, + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: turn_summary.started_at, + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2871,6 +2919,9 @@ mod tests { Arc::new(Mutex::new(ThreadState::default())) } + const TEST_TURN_COMPLETED_AT: i64 = 1_716_000_456; + const TEST_TURN_DURATION_MS: i64 = 1_234; + async fn recv_broadcast_message( rx: &mut mpsc::Receiver, ) -> Result { @@ -2884,6 +2935,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { command: command.to_string(), @@ -3648,10 +3717,25 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + { + let mut state = thread_state.lock().await; + state.track_current_turn_event(&EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + )); + state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + &event_turn_id, + ))); + } handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3663,6 +3747,9 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3696,6 +3783,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3707,6 +3795,8 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Interrupted); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3740,6 +3830,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3758,6 +3849,8 @@ mod tests { additional_details: None, }) ); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -4000,7 +4093,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -4014,11 +4114,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8d9dbe05e..da225413e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6581,6 +6581,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6917,6 +6920,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, } } @@ -9566,6 +9572,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 71beb58dc..5d8cf052c 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -826,6 +826,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 17823fefd..0fe835fc7 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -45,6 +45,7 @@ pub(crate) enum ThreadListenerCommand { /// Per-conversation accumulation of the latest states e.g. error message while a turn runs. #[derive(Default, Clone)] pub(crate) struct TurnSummary { + pub(crate) started_at: Option, pub(crate) file_change_started: HashSet, pub(crate) command_execution_started: HashSet, pub(crate) last_error: Option, @@ -110,8 +111,13 @@ impl ThreadState { } pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { + if let EventMsg::TurnStarted(payload) = event { + self.turn_summary.started_at = payload.started_at; + } self.current_turn_history.handle_event(event); - if !self.current_turn_history.has_active_turn() { + if matches!(event, EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_)) + && !self.current_turn_history.has_active_turn() + { self.current_turn_history.reset(); } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 83ed147e1..2b1d6a8dd 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index d254a4934..a64a11bf9 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -256,6 +256,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -267,6 +268,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -288,6 +291,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1200,6 +1205,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1284,6 +1291,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e1..753244ac2 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e..fbdeb765a 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 85404d4d5..3a7ceb6f8 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -235,13 +235,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1300,6 +1306,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1317,6 +1324,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1481,6 +1490,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1499,10 +1509,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1521,6 +1534,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1579,6 +1594,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1595,10 +1611,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1610,10 +1629,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1634,6 +1656,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1661,6 +1685,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1677,10 +1702,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1697,10 +1725,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1717,6 +1748,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4624,6 +4657,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf06926..8300dc650 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8..5bc7944d2 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 527292158..f33e6886f 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7a..2a26dbcca 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b..3181e7369 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b..6d93a1a32 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68af..3db173582 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f3..eab43f899 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e45..a4451a52f 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a9927..f9ad64c77 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd56..232be7f02 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed0..2a26ec3c7 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 4746ed20d..0af5cc525 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], }; @@ -303,6 +309,9 @@ fn should_backfill_turn_completed_items_skips_ephemeral_threads() { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }); diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e..c22207c91 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e..7116878b4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub started_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac9..89feb139d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + started_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,11 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + duration_ms: Some(1), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10434,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10457,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e3..995af44b2 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + started_at: notification.turn.started_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe124..6bae21736 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b..d152e99f6 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 34c0df7b4..786911f15 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5889,6 +5889,9 @@ impl ChatWidget { items, status, error, + started_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5909,6 +5912,9 @@ impl ChatWidget { items: Vec::new(), status, error, + started_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc..b5e9995d6 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b9..1bae5d2aa 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3c..68a565924 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e..4f43b498d 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f..8b1180cfa 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d..29b1b8c59 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a0..c42ec4fba 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index c6fff5989..fdf7c5008 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -656,6 +672,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba8..00be80d4e 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }),