diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index bbbb109eff74..eeab138afd36 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1134,6 +1134,7 @@ mod tests { ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { thread_id: "thread".to_string(), turn_id: "turn".to_string(), + completed_at_ms: None, item: codex_app_server_protocol::ThreadItem::AgentMessage { id: "item".to_string(), text: text.to_string(), @@ -2007,6 +2008,7 @@ mod tests { codex_app_server_protocol::ItemCompletedNotification { thread_id: "thread".to_string(), turn_id: "turn".to_string(), + completed_at_ms: None, item: codex_app_server_protocol::ThreadItem::AgentMessage { id: "item".to_string(), text: "hello".to_string(), diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 82914f3a6f22..4f4db000aa16 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1932,6 +1932,14 @@ }, "ItemCompletedNotification": { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "item": { "$ref": "#/definitions/ThreadItem" }, @@ -2030,6 +2038,14 @@ "item": { "$ref": "#/definitions/ThreadItem" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, 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 f856b43d6607..0fd436ad4c48 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 @@ -10109,6 +10109,14 @@ "ItemCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "item": { "$ref": "#/definitions/v2/ThreadItem" }, @@ -10213,6 +10221,14 @@ "item": { "$ref": "#/definitions/v2/ThreadItem" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, 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 c17efe7a4533..5cbf38727101 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 @@ -6762,6 +6762,14 @@ "ItemCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "item": { "$ref": "#/definitions/ThreadItem" }, @@ -6866,6 +6874,14 @@ "item": { "$ref": "#/definitions/ThreadItem" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 0831483a327f..39d75f5311e5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -1370,6 +1370,14 @@ } }, "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "item": { "$ref": "#/definitions/ThreadItem" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 16bfeece144a..e01c792ea4f5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -1373,6 +1373,14 @@ "item": { "$ref": "#/definitions/ThreadItem" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts index 96122204b43c..4f0e9b1fcf23 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadItem } from "./ThreadItem"; -export type ItemCompletedNotification = { item: ThreadItem, threadId: string, turnId: string, }; +export type ItemCompletedNotification = { item: ThreadItem, threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this item lifecycle completed, if known. + */ +completedAtMs: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts index 5cf1e7b91881..86f62a2ba396 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadItem } from "./ThreadItem"; -export type ItemStartedNotification = { item: ThreadItem, threadId: string, turnId: string, }; +export type ItemStartedNotification = { item: ThreadItem, threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this item lifecycle started, if known. + */ +startedAtMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs index 809f08050fcb..88081f4f6434 100644 --- a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs +++ b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs @@ -73,6 +73,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id: response.turn_id, item, + completed_at_ms: None, }) } EventMsg::McpToolCallBegin(begin_event) => { @@ -91,6 +92,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: None, }) } EventMsg::McpToolCallEnd(end_event) => { @@ -131,6 +133,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: None, }) } EventMsg::CollabAgentSpawnBegin(begin_event) => { @@ -149,6 +152,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: None, }) } EventMsg::CollabAgentSpawnEnd(end_event) => { @@ -187,6 +191,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: None, }) } EventMsg::CollabAgentInteractionBegin(begin_event) => { @@ -206,6 +211,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: None, }) } EventMsg::CollabAgentInteractionEnd(end_event) => { @@ -233,6 +239,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: None, }) } EventMsg::CollabWaitingBegin(begin_event) => { @@ -256,6 +263,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: None, }) } EventMsg::CollabWaitingEnd(end_event) => { @@ -291,6 +299,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: None, }) } EventMsg::CollabCloseBegin(begin_event) => { @@ -309,6 +318,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: None, }) } EventMsg::CollabCloseEnd(end_event) => { @@ -341,6 +351,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: None, }) } EventMsg::CollabResumeBegin(begin_event) => { @@ -359,6 +370,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: None, }) } EventMsg::CollabResumeEnd(end_event) => { @@ -391,6 +403,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: None, }) } EventMsg::AgentMessageContentDelta(event) => { @@ -440,6 +453,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item: item_started_event.item.into(), + started_at_ms: item_started_event.started_at_ms, }) } EventMsg::ItemCompleted(item_completed_event) => { @@ -447,6 +461,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item: item_completed_event.item.into(), + completed_at_ms: item_completed_event.completed_at_ms, }) } EventMsg::PatchApplyUpdated(event) => { @@ -462,6 +477,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item: build_command_execution_begin_item(&exec_command_begin_event), + started_at_ms: None, }) } EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => { @@ -490,6 +506,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item: build_command_execution_end_item(&exec_command_end_event), + completed_at_ms: None, }) } _ => unreachable!("unsupported item event"), @@ -564,6 +581,7 @@ mod tests { ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, item: ThreadItem::CollabAgentToolCall { id: event.call_id, tool: CollabAgentTool::ResumeAgent, @@ -601,6 +619,7 @@ mod tests { ItemCompletedNotification { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), + completed_at_ms: None, item: ThreadItem::CollabAgentToolCall { id: event.call_id, tool: CollabAgentTool::ResumeAgent, @@ -643,6 +662,7 @@ mod tests { ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn_1".to_string(), + started_at_ms: None, item: ThreadItem::McpToolCall { id: begin_event.call_id, server: begin_event.invocation.server, @@ -680,6 +700,7 @@ mod tests { ItemStartedNotification { thread_id: "thread-2".to_string(), turn_id: "turn_2".to_string(), + started_at_ms: None, item: ThreadItem::McpToolCall { id: begin_event.call_id, server: begin_event.invocation.server, @@ -732,6 +753,7 @@ mod tests { ItemCompletedNotification { thread_id: "thread-3".to_string(), turn_id: "turn_3".to_string(), + completed_at_ms: None, item: ThreadItem::McpToolCall { id: end_event.call_id, server: end_event.invocation.server, @@ -777,6 +799,7 @@ mod tests { ItemCompletedNotification { thread_id: "thread-4".to_string(), turn_id: "turn_4".to_string(), + completed_at_ms: None, item: ThreadItem::McpToolCall { id: end_event.call_id, server: end_event.invocation.server, 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 b1f23bb8fb3f..27ee3901545a 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1352,6 +1352,7 @@ mod tests { id: "user-item-id".to_string(), content: Vec::new(), }), + started_at_ms: None, }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index fe55a8714e6b..c524559b5088 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -6874,6 +6874,9 @@ pub struct ItemStartedNotification { pub item: ThreadItem, pub thread_id: String, pub turn_id: String, + /// Unix timestamp (in milliseconds) when this item lifecycle started, if known. + #[ts(type = "number | null")] + pub started_at_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -6936,6 +6939,9 @@ pub struct ItemCompletedNotification { pub item: ThreadItem, pub thread_id: String, pub turn_id: String, + /// Unix timestamp (in milliseconds) when this item lifecycle completed, if known. + #[ts(type = "number | null")] + pub completed_at_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, 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 bb77a71705e0..18aac33646e2 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -817,6 +817,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: turn_id.clone(), item, + started_at_ms: None, }; outgoing .send_server_notification(ServerNotification::ItemStarted(notification)) @@ -963,6 +964,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), item: item.clone(), + started_at_ms: None, }; outgoing .send_server_notification(ServerNotification::ItemStarted(started)) @@ -971,6 +973,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), item, + completed_at_ms: None, }; outgoing .send_server_notification(ServerNotification::ItemCompleted(completed)) @@ -988,6 +991,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), item: item.clone(), + started_at_ms: None, }; outgoing .send_server_notification(ServerNotification::ItemStarted(started)) @@ -996,6 +1000,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), item, + completed_at_ms: None, }; outgoing .send_server_notification(ServerNotification::ItemCompleted(completed)) @@ -1045,6 +1050,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), item: item.clone(), + started_at_ms: None, }; outgoing .send_server_notification(ServerNotification::ItemStarted(started)) @@ -1053,6 +1059,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), item, + completed_at_ms: None, }; outgoing .send_server_notification(ServerNotification::ItemCompleted(completed)) @@ -1399,6 +1406,7 @@ async fn start_command_execution_item( exit_code: None, duration_ms: None, }, + started_at_ms: None, }; outgoing .send_server_notification(ServerNotification::ItemStarted(notification)) @@ -1447,6 +1455,7 @@ async fn complete_command_execution_item( thread_id: conversation_id.to_string(), turn_id, item, + completed_at_ms: None, }; outgoing .send_server_notification(ServerNotification::ItemCompleted(notification)) @@ -1501,6 +1510,7 @@ pub(crate) async fn maybe_emit_hook_prompt_item_completed( .map(codex_app_server_protocol::HookPromptFragment::from) .collect(), }, + completed_at_ms: None, }; outgoing .send_server_notification(ServerNotification::ItemCompleted(notification)) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index c45a8b638a5f..ed0213a3a43b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -42,6 +42,7 @@ use crate::session_prefix::format_subagent_notification_message; use crate::skills::SkillRenderSideEffects; use crate::skills_load_input_from_config; use crate::turn_metadata::TurnMetadataState; +use crate::turn_timing::now_unix_timestamp_ms; use async_channel::Receiver; use async_channel::Sender; use chrono::Local; @@ -1647,6 +1648,7 @@ impl Session { thread_id: self.conversation_id, turn_id: turn_context.sub_id.clone(), item: item.clone(), + started_at_ms: Some(now_unix_timestamp_ms()), }), ) .await; @@ -1664,6 +1666,7 @@ impl Session { thread_id: self.conversation_id, turn_id: turn_context.sub_id.clone(), item, + completed_at_ms: Some(now_unix_timestamp_ms()), }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index d6bf37253f6e..1d0232cde7ac 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -107,7 +107,7 @@ fn now_unix_timestamp_secs() -> i64 { now_unix_timestamp_ms() / 1000 } -fn now_unix_timestamp_ms() -> i64 { +pub(crate) fn now_unix_timestamp_ms() -> i64 { let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 2e60823c0c15..a7399ad7677d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -303,6 +303,15 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { }) .await?; + let started = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::WebSearch(item), + started_at_ms, + .. + }) => Some((item.clone(), *started_at_ms)), + _ => None, + }) + .await; let begin = wait_for_event_match(&codex, |ev| match ev { EventMsg::WebSearchBegin(event) => Some(event.clone()), _ => None, @@ -311,16 +320,20 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { let completed = wait_for_event_match(&codex, |ev| match ev { EventMsg::ItemCompleted(ItemCompletedEvent { item: TurnItem::WebSearch(item), + completed_at_ms, .. - }) => Some(item.clone()), + }) => Some((item.clone(), *completed_at_ms)), _ => None, }) .await; assert_eq!(begin.call_id, "web-search-1"); - assert_eq!(completed.id, begin.call_id); + assert_eq!(started.0.id, begin.call_id); + assert!(started.1.is_some()); + assert_eq!(completed.0.id, begin.call_id); + assert!(completed.1.is_some()); assert_eq!( - completed.action, + completed.0.action, WebSearchAction::Search { query: Some("weather seattle".to_string()), queries: None, @@ -369,11 +382,29 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { }) .await?; + let started = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::ImageGeneration(item), + started_at_ms, + .. + }) => Some((item.clone(), *started_at_ms)), + _ => None, + }) + .await; let begin = wait_for_event_match(&codex, |ev| match ev { EventMsg::ImageGenerationBegin(event) => Some(event.clone()), _ => None, }) .await; + let completed = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::ImageGeneration(item), + completed_at_ms, + .. + }) => Some((item.clone(), *completed_at_ms)), + _ => None, + }) + .await; let end = wait_for_event_match(&codex, |ev| match ev { EventMsg::ImageGenerationEnd(event) => Some(event.clone()), _ => None, @@ -381,6 +412,10 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { .await; assert_eq!(begin.call_id, call_id); + assert_eq!(started.0.id, call_id); + assert!(started.1.is_some()); + assert_eq!(completed.0.id, call_id); + assert!(completed.1.is_some()); assert_eq!(end.call_id, call_id); assert_eq!(end.status, "completed"); assert_eq!(end.revised_prompt, Some("A tiny blue square".to_string())); 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 2a26ec3c7e89..aba5f4653acc 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 @@ -20,6 +20,7 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_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 f6f6d35f2151..e8a0758d850d 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -181,6 +181,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() { item: command_item, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, })); assert_eq!( started, @@ -216,6 +217,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); assert_eq!( @@ -250,6 +252,7 @@ fn empty_reasoning_items_are_ignored() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -274,6 +277,7 @@ fn unsupported_items_do_not_consume_synthetic_ids() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -295,6 +299,7 @@ fn unsupported_items_do_not_consume_synthetic_ids() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -327,6 +332,7 @@ fn reasoning_items_emit_summary_not_raw_content() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -362,6 +368,7 @@ fn web_search_completion_preserves_query_and_action() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -399,6 +406,7 @@ fn web_search_start_and_completion_reuse_item_id() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, })); let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( @@ -413,6 +421,7 @@ fn web_search_start_and_completion_reuse_item_id() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -472,6 +481,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, })); let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( ItemCompletedNotification { @@ -492,6 +502,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -559,6 +570,7 @@ fn mcp_tool_call_failure_sets_failed_status() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -604,6 +616,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, })); let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( ItemCompletedNotification { @@ -627,6 +640,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -695,6 +709,7 @@ fn collab_spawn_begin_and_end_emit_item_events() { }, thread_id: "thread-parent".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, })); let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( ItemCompletedNotification { @@ -717,6 +732,7 @@ fn collab_spawn_begin_and_end_emit_item_events() { }, thread_id: "thread-parent".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -795,6 +811,7 @@ fn file_change_completion_maps_change_kinds() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -845,6 +862,7 @@ fn file_change_declined_maps_to_failed_status() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -882,6 +900,7 @@ fn agent_message_item_updates_final_message() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -916,6 +935,7 @@ fn agent_message_item_started_is_ignored() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, })); assert_eq!( @@ -940,6 +960,7 @@ fn reasoning_item_completed_uses_synthetic_id() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -1296,6 +1317,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, })); assert_eq!( started, @@ -1378,6 +1400,7 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -1426,6 +1449,7 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); @@ -1470,6 +1494,7 @@ fn failed_turn_clears_stale_final_message() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f4b3a52d9733..5247e4fbba62 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1828,6 +1828,9 @@ pub struct ItemStartedEvent { pub thread_id: ThreadId, pub turn_id: String, pub item: TurnItem, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, } impl HasLegacyEvent for ItemStartedEvent { @@ -1852,6 +1855,9 @@ pub struct ItemCompletedEvent { pub thread_id: ThreadId, pub turn_id: String, pub item: TurnItem, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, } pub trait HasLegacyEvent { @@ -4591,6 +4597,7 @@ mod tests { queries: None, }, }), + started_at_ms: None, }; let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false); @@ -4607,6 +4614,7 @@ mod tests { thread_id: ThreadId::new(), turn_id: "turn-1".into(), item: TurnItem::UserMessage(UserMessageItem::new(&[])), + started_at_ms: None, }; assert!( @@ -4628,6 +4636,7 @@ mod tests { result: String::new(), saved_path: None, }), + started_at_ms: None, }; let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false); @@ -4658,6 +4667,7 @@ mod tests { stdout: None, stderr: None, }), + started_at_ms: None, }; let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false); @@ -4685,6 +4695,7 @@ mod tests { result: "Zm9v".into(), saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()), }), + completed_at_ms: None, }; let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false); @@ -4724,6 +4735,7 @@ mod tests { stdout: Some("Done!".into()), stderr: Some(String::new()), }), + completed_at_ms: None, }; let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false); diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 84500e3edfe0..9a2d7d788c2d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2597,6 +2597,7 @@ async fn inactive_thread_file_change_approval_recovers_buffered_changes() { ServerNotification::ItemStarted(ItemStartedNotification { thread_id: thread_id.to_string(), turn_id: "turn-approval".to_string(), + started_at_ms: None, item: ThreadItem::FileChange { id: "patch-approval".to_string(), changes: vec![FileUpdateChange { @@ -4697,6 +4698,7 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { codex_app_server_protocol::ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, item: ThreadItem::CollabAgentToolCall { id: "wait-1".to_string(), tool: codex_app_server_protocol::CollabAgentTool::Wait, diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 13b86e2afbfd..24b4319e463b 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -16,6 +16,7 @@ async fn collab_spawn_end_shows_requested_model_and_effort() { ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, item: AppServerThreadItem::CollabAgentToolCall { id: "call-spawn".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, @@ -34,6 +35,7 @@ async fn collab_spawn_end_shows_requested_model_and_effort() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: AppServerThreadItem::CollabAgentToolCall { id: "call-spawn".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, @@ -90,6 +92,7 @@ async fn live_app_server_user_message_item_completed_does_not_duplicate_rendered ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: AppServerThreadItem::UserMessage { id: "user-1".to_string(), content: vec![AppServerUserInput::Text { @@ -135,6 +138,7 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: AppServerThreadItem::AgentMessage { id: "msg-1".to_string(), text: "Yes. What do you need?".to_string(), @@ -287,6 +291,7 @@ async fn live_app_server_file_change_item_started_preserves_changes() { ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, item: AppServerThreadItem::FileChange { id: "patch-1".to_string(), changes: vec![FileUpdateChange { @@ -320,6 +325,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, item: AppServerThreadItem::CommandExecution { id: "cmd-1".to_string(), command: command.clone(), @@ -341,6 +347,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: AppServerThreadItem::CommandExecution { id: "cmd-1".to_string(), command, @@ -396,6 +403,7 @@ async fn live_app_server_collab_wait_items_render_history() { ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, item: AppServerThreadItem::CollabAgentToolCall { id: "wait-1".to_string(), tool: AppServerCollabAgentTool::Wait, @@ -418,6 +426,7 @@ async fn live_app_server_collab_wait_items_render_history() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: AppServerThreadItem::CollabAgentToolCall { id: "wait-1".to_string(), tool: AppServerCollabAgentTool::Wait, @@ -471,6 +480,7 @@ async fn live_app_server_collab_spawn_completed_renders_requested_model_and_effo ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, item: AppServerThreadItem::CollabAgentToolCall { id: "spawn-1".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, @@ -490,6 +500,7 @@ async fn live_app_server_collab_spawn_completed_renders_requested_model_and_effo ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: AppServerThreadItem::CollabAgentToolCall { id: "spawn-1".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 04f7e3d90714..ecc7e9d8e1d4 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -654,6 +654,7 @@ pub(super) fn handle_agent_reasoning_final(chat: &mut ChatWidget) { .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), + completed_at_ms: None, item: AppServerThreadItem::Reasoning { id: "reasoning-1".to_string(), summary: Vec::new(), @@ -672,6 +673,7 @@ pub(super) fn handle_entered_review_mode(chat: &mut ChatWidget, review: impl Int .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), + started_at_ms: None, item: AppServerThreadItem::EnteredReviewMode { id: "review-start".to_string(), review: review.into(), @@ -700,6 +702,7 @@ pub(super) fn handle_exited_review_mode(chat: &mut ChatWidget) { .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), + completed_at_ms: None, item: AppServerThreadItem::ExitedReviewMode { id: "review-end".to_string(), review: String::new(), @@ -756,6 +759,7 @@ pub(super) fn handle_patch_apply_begin( ServerNotification::ItemStarted(ItemStartedNotification { thread_id: thread_id(chat), turn_id: turn_id.into(), + started_at_ms: None, item: AppServerThreadItem::FileChange { id: call_id.into(), changes: file_update_changes_from_tui(changes), @@ -777,6 +781,7 @@ pub(super) fn handle_patch_apply_end( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: thread_id(chat), turn_id: turn_id.into(), + completed_at_ms: None, item: AppServerThreadItem::FileChange { id: call_id.into(), changes: file_update_changes_from_tui(changes), @@ -796,6 +801,7 @@ pub(super) fn handle_view_image_tool_call( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: thread_id(chat), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: AppServerThreadItem::ImageView { id: call_id.into(), path, @@ -815,6 +821,7 @@ pub(super) fn handle_image_generation_end( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: thread_id(chat), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: AppServerThreadItem::ImageGeneration { id: call_id.into(), status: "completed".to_string(), @@ -972,6 +979,7 @@ pub(super) fn handle_exec_begin(chat: &mut ChatWidget, item: AppServerThreadItem .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), + started_at_ms: None, item, }), /*replay_kind*/ None, @@ -1011,6 +1019,7 @@ pub(super) fn complete_assistant_message( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: AppServerThreadItem::AgentMessage { id: item_id.to_string(), text: text.to_string(), @@ -1053,6 +1062,7 @@ pub(super) fn complete_user_message_for_inputs( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: AppServerThreadItem::UserMessage { id: item_id.to_string(), content, @@ -1194,6 +1204,7 @@ pub(super) fn handle_exec_end(chat: &mut ChatWidget, item: AppServerThreadItem) .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), + completed_at_ms: None, item, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index d801870bca0f..ec18f7fdc0e5 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -801,6 +801,7 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: AppServerThreadItem::Reasoning { id: "reasoning-1".to_string(), summary: vec!["Summary only".to_string()], diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 8b851676045d..de4248330b35 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -154,6 +154,7 @@ async fn live_app_server_review_prompt_item_is_not_rendered() { ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: None, item: review_mode_item.clone(), }), /*replay_kind*/ None, @@ -166,6 +167,7 @@ async fn live_app_server_review_prompt_item_is_not_rendered() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: review_mode_item, }), /*replay_kind*/ None, @@ -176,6 +178,7 @@ async fn live_app_server_review_prompt_item_is_not_rendered() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: AppServerThreadItem::UserMessage { id: "review-prompt".to_string(), content: vec![AppServerUserInput::Text { diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 53312a7f8159..37018ea381aa 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1149,6 +1149,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: String::new(), turn_id: "turn-1".to_string(), + completed_at_ms: None, item: AppServerThreadItem::Plan { id: "plan-1".to_string(), text: plan_text.clone(),