diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 515fcc56379e..98cbcf1cb3ce 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -3,12 +3,17 @@ use crate::events::AppServerRpcTransport; use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexCommandExecutionEventParams; +use crate::events::CodexCommandExecutionEventRequest; use crate::events::CodexCompactionEventRequest; use crate::events::CodexHookRunEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolItemEventBase; use crate::events::CodexTurnEventRequest; +use crate::events::CommandExecutionFamily; +use crate::events::CommandExecutionSource; use crate::events::GuardianApprovalRequestSource; use crate::events::GuardianReviewDecision; use crate::events::GuardianReviewEventParams; @@ -17,6 +22,8 @@ use crate::events::GuardianReviewTerminalStatus; use crate::events::GuardianReviewedAction; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolItemFinalApprovalOutcome; +use crate::events::ToolItemTerminalStatus; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_hook_run_metadata; @@ -61,8 +68,12 @@ use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CommandExecutionSource as AppServerCommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; @@ -72,6 +83,7 @@ use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; @@ -549,6 +561,76 @@ async fn ingest_turn_prerequisites( } } +async fn ingest_tool_review_prerequisites( + reducer: &mut AnalyticsReducer, + events: &mut Vec, +) { + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), events) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(1), + response: Box::new(sample_thread_start_response( + "thread-1", /*ephemeral*/ false, "gpt-5", + )), + }, + events, + ) + .await; + events.clear(); +} + +fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact { + AnalyticsFact::Initialize { + connection_id, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + }, + product_client_id: DEFAULT_ORIGINATOR.to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.99.0".to_string(), + runtime_os: "linux".to_string(), + runtime_os_version: "24.04".to_string(), + runtime_arch: "x86_64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Websocket, + } +} + +fn sample_command_execution_item( + status: CommandExecutionStatus, + exit_code: Option, + started_at_ms: Option, + completed_at_ms: Option, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "item-1".to_string(), + command: "echo hi".to_string(), + cwd: test_path_buf("/tmp").abs(), + process_id: Some("pid-1".to_string()), + source: AppServerCommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + started_at_ms, + completed_at_ms, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -852,6 +934,101 @@ fn thread_initialized_event_serializes_expected_shape() { ); } +#[test] +fn command_execution_event_serializes_expected_shape() { + let event = TrackEventRequest::CommandExecution(CodexCommandExecutionEventRequest { + event_type: "codex_command_execution_event", + event_params: CodexCommandExecutionEventParams { + base: CodexToolItemEventBase { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + app_server_client: CodexAppServerClientMetadata { + product_client_id: "codex_tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.2.3".to_string()), + rpc_transport: AppServerRpcTransport::Websocket, + experimental_api_enabled: Some(true), + }, + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.99.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + thread_source: Some("user"), + subagent_source: None, + parent_thread_id: None, + tool_name: "shell".to_string(), + started_at_ms: 123_000, + completed_at_ms: Some(125_000), + duration_ms: Some(2000), + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_approval_outcome: ToolItemFinalApprovalOutcome::NotNeeded, + terminal_status: ToolItemTerminalStatus::Completed, + failure_kind: None, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + command_execution_source: CommandExecutionSource::Agent, + command_execution_family: CommandExecutionFamily::Shell, + exit_code: Some(0), + command_action_count: Some(1), + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize command execution event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_command_execution_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "item_id": "item-1", + "app_server_client": { + "product_client_id": "codex_tui", + "client_name": "codex-tui", + "client_version": "1.2.3", + "rpc_transport": "websocket", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.99.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "thread_source": "user", + "subagent_source": null, + "parent_thread_id": null, + "tool_name": "shell", + "started_at_ms": 123000, + "completed_at_ms": 125000, + "duration_ms": 2000, + "execution_started": true, + "review_count": 0, + "guardian_review_count": 0, + "user_review_count": 0, + "final_approval_outcome": "not_needed", + "terminal_status": "completed", + "failure_kind": null, + "requested_additional_permissions": false, + "requested_network_access": false, + "retry_count": 0, + "command_execution_source": "agent", + "command_execution_family": "shell", + "exit_code": 0, + "command_action_count": 1 + } + }) + ); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); @@ -1257,6 +1434,90 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { assert_eq!(payload[0]["event_params"]["review_timeout_ms"], 90_000); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_command_execution_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_tool_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( + ItemStartedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item: sample_command_execution_item( + CommandExecutionStatus::InProgress, + /*exit_code*/ None, + Some(1_000), + /*completed_at_ms*/ None, + /*duration_ms*/ None, + ), + }, + ))), + &mut events, + ) + .await; + assert!( + events.is_empty(), + "tool item event should emit on completion" + ); + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item: sample_command_execution_item( + CommandExecutionStatus::Completed, + Some(0), + Some(1_000), + Some(1_042), + Some(42), + ), + }, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_command_execution_event"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-1"); + assert_eq!(payload[0]["event_params"]["item_id"], "item-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!( + payload[0]["event_params"]["command_execution_source"], + "agent" + ); + assert_eq!( + payload[0]["event_params"]["command_execution_family"], + "shell" + ); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + assert_eq!( + payload[0]["event_params"]["final_approval_outcome"], + "unknown" + ); + assert_eq!( + payload[0]["event_params"]["failure_kind"], + serde_json::Value::Null + ); + assert_eq!(payload[0]["event_params"]["exit_code"], 0); + assert_eq!(payload[0]["event_params"]["started_at_ms"], 1_000); + assert_eq!(payload[0]["event_params"]["completed_at_ms"], 1_042); + assert_eq!(payload[0]["event_params"]["duration_ms"], 42); + assert_eq!(payload[0]["event_params"]["execution_started"], true); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["client_name"], + "codex-tui" + ); + assert_eq!(payload[0]["event_params"]["thread_source"], "user"); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( @@ -1436,6 +1697,450 @@ async fn subagent_thread_started_publishes_without_initialize() { assert_eq!(payload[0]["event_params"]["subagent_source"], "review"); } +#[tokio::test] +async fn subagent_thread_started_preserves_existing_connection_when_parent_lookup_fails() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let parent_thread_id = + codex_protocol::ThreadId::from_string("22222222-2222-2222-2222-222222222222") + .expect("valid parent thread id"); + + ingest_initialize(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(2), + response: Box::new(sample_thread_resume_response_with_source( + "thread-review", + /*ephemeral*/ false, + "gpt-5", + AppServerSessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + )), + }, + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( + SubAgentThreadStartedInput { + thread_id: "thread-review".to_string(), + parent_thread_id: Some("missing-parent".to_string()), + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Other("guardian".to_string()), + created_at: 128, + }, + )), + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::Compaction(Box::new( + CodexCompactionEvent { + thread_id: "thread-review".to_string(), + turn_id: "turn-compact".to_string(), + trigger: CompactionTrigger::Manual, + reason: CompactionReason::UserRequested, + implementation: CompactionImplementation::Responses, + phase: CompactionPhase::StandaloneTurn, + strategy: CompactionStrategy::Memento, + status: CompactionStatus::Failed, + error: Some("context limit exceeded".to_string()), + active_context_tokens_before: 131_000, + active_context_tokens_after: 131_000, + started_at: 100, + completed_at: 101, + duration_ms: Some(1200), + }, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_compaction_event"); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + "codex-tui" + ); + assert_eq!( + payload[0]["event_params"]["subagent_source"], + "thread_spawn" + ); +} + +#[tokio::test] +async fn subagent_thread_started_preserves_existing_connection_when_parent_lookup_succeeds() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let parent_thread_id = + codex_protocol::ThreadId::from_string("44444444-4444-4444-4444-444444444444") + .expect("valid parent thread id"); + let parent_thread_id_string = parent_thread_id.to_string(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "parent-client".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "parent-client".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 8, + params: InitializeParams { + client_info: ClientInfo { + name: "child-client".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "child-client".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(1), + response: Box::new(sample_thread_start_response( + &parent_thread_id_string, + /*ephemeral*/ false, + "gpt-5", + )), + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 8, + request_id: RequestId::Integer(2), + response: Box::new(sample_thread_resume_response_with_source( + "thread-review", + /*ephemeral*/ false, + "gpt-5", + AppServerSessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + )), + }, + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( + SubAgentThreadStartedInput { + thread_id: "thread-review".to_string(), + parent_thread_id: Some(parent_thread_id_string), + product_client_id: "child-client".to_string(), + client_name: "child-client".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Other("guardian".to_string()), + created_at: 130, + }, + )), + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::Compaction(Box::new( + CodexCompactionEvent { + thread_id: "thread-review".to_string(), + turn_id: "turn-compact".to_string(), + trigger: CompactionTrigger::Manual, + reason: CompactionReason::UserRequested, + implementation: CompactionImplementation::Responses, + phase: CompactionPhase::StandaloneTurn, + strategy: CompactionStrategy::Memento, + status: CompactionStatus::Failed, + error: Some("context limit exceeded".to_string()), + active_context_tokens_before: 131_000, + active_context_tokens_after: 131_000, + started_at: 100, + completed_at: 101, + duration_ms: Some(1200), + }, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_compaction_event"); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + "child-client" + ); +} + +#[tokio::test] +async fn subagent_thread_started_preserves_resumed_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let parent_thread_id = + codex_protocol::ThreadId::from_string("33333333-3333-3333-3333-333333333333") + .expect("valid parent thread id"); + let parent_thread_id_string = parent_thread_id.to_string(); + + ingest_initialize(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(1), + response: Box::new(sample_thread_start_response( + &parent_thread_id_string, + /*ephemeral*/ false, + "gpt-5", + )), + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(2), + response: Box::new(sample_thread_resume_response_with_source( + "thread-review", + /*ephemeral*/ false, + "gpt-5", + AppServerSessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + )), + }, + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( + SubAgentThreadStartedInput { + thread_id: "thread-review".to_string(), + parent_thread_id: Some(parent_thread_id_string.clone()), + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Other("guardian".to_string()), + created_at: 129, + }, + )), + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::ClientRequest { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request( + "thread-review", + /*request_id*/ 3, + )), + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + response: Box::new(sample_turn_start_response("turn-review")), + }, + &mut events, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-review"); + resolved_config.thread_id = "thread-review".to_string(); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-review", + "turn-review", + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-review", "turn-review"), + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-review", + "turn-review", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_turn_event"); + assert_eq!(payload[0]["event_params"]["initialization_mode"], "resumed"); + assert_eq!( + payload[0]["event_params"]["subagent_source"], + "thread_spawn" + ); + assert_eq!( + payload[0]["event_params"]["parent_thread_id"], + parent_thread_id_string + ); +} + +#[tokio::test] +async fn subagent_tool_items_inherit_parent_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_tool_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( + SubAgentThreadStartedInput { + thread_id: "thread-subagent".to_string(), + parent_thread_id: Some("thread-1".to_string()), + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Review, + created_at: 128, + }, + )), + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( + ItemStartedNotification { + thread_id: "thread-subagent".to_string(), + turn_id: "turn-subagent".to_string(), + item: sample_command_execution_item( + CommandExecutionStatus::InProgress, + /*exit_code*/ None, + Some(1_000), + /*completed_at_ms*/ None, + /*duration_ms*/ None, + ), + }, + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: "thread-subagent".to_string(), + turn_id: "turn-subagent".to_string(), + item: sample_command_execution_item( + CommandExecutionStatus::Completed, + Some(0), + Some(1_000), + Some(1_042), + Some(42), + ), + }, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_command_execution_event"); + assert_eq!(payload[0]["event_params"]["thread_source"], "subagent"); + assert_eq!(payload[0]["event_params"]["subagent_source"], "review"); + assert_eq!(payload[0]["event_params"]["parent_thread_id"], "thread-1"); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["client_name"], + "codex-tui" + ); +} + #[test] fn plugin_used_event_serializes_expected_shape() { let tracking = TrackEventsContext { @@ -1989,7 +2694,7 @@ async fn accepted_turn_steer_emits_expected_event() { } #[tokio::test] -async fn rejected_turn_steer_uses_request_connection_metadata() { +async fn rejected_turn_steer_uses_thread_connection_metadata() { let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); let payload = ingest_rejected_turn_steer( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index d54c53ede921..6d6d44656054 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -333,10 +333,6 @@ impl AnalyticsEventsClient { }); } - pub fn track_notification(&self, notification: ServerNotification) { - self.record_fact(AnalyticsFact::Notification(Box::new(notification))); - } - pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) { self.record_fact(AnalyticsFact::ServerRequest { connection_id, @@ -349,6 +345,21 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + if !matches!( + notification, + ServerNotification::TurnStarted(_) + | ServerNotification::TurnCompleted(_) + | ServerNotification::ItemStarted(_) + | ServerNotification::ItemCompleted(_) + | ServerNotification::ItemGuardianApprovalReviewStarted(_) + | ServerNotification::ItemGuardianApprovalReviewCompleted(_) + ) { + return; + } + self.record_fact(AnalyticsFact::Notification(Box::new(notification))); + } } async fn send_track_events( diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 7120960b627a..a061af7d286e 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -61,6 +61,22 @@ pub(crate) enum TrackEventRequest { Compaction(Box), TurnEvent(Box), TurnSteer(CodexTurnSteerEventRequest), + #[allow(dead_code)] + CommandExecution(CodexCommandExecutionEventRequest), + #[allow(dead_code)] + FileChange(CodexFileChangeEventRequest), + #[allow(dead_code)] + McpToolCall(CodexMcpToolCallEventRequest), + #[allow(dead_code)] + DynamicToolCall(CodexDynamicToolCallEventRequest), + #[allow(dead_code)] + CollabAgentToolCall(CodexCollabAgentToolCallEventRequest), + #[allow(dead_code)] + WebSearch(CodexWebSearchEventRequest), + #[allow(dead_code)] + ImageGeneration(CodexImageGenerationEventRequest), + #[allow(dead_code)] + ToolCallReview(CodexToolCallReviewEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -384,6 +400,285 @@ pub(crate) struct GuardianReviewEventPayload { pub(crate) guardian_review: GuardianReviewEventParams, } +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolItemFinalApprovalOutcome { + Unknown, + NotNeeded, + ConfigAllowed, + PolicyForbidden, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolItemTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolItemFailureKind { + ToolError, + ApprovalDenied, + ApprovalAborted, + SandboxDenied, + PolicyForbidden, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolItemEventBase { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + /// App-server ThreadItem.id. For tool-originated items this generally + /// corresponds to the originating core call_id. + pub(crate) item_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option<&'static str>, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) tool_name: String, + pub(crate) started_at_ms: u64, + pub(crate) completed_at_ms: Option, + pub(crate) duration_ms: Option, + pub(crate) execution_started: bool, + pub(crate) review_count: u64, + pub(crate) guardian_review_count: u64, + pub(crate) user_review_count: u64, + pub(crate) final_approval_outcome: ToolItemFinalApprovalOutcome, + pub(crate) terminal_status: ToolItemTerminalStatus, + pub(crate) failure_kind: Option, + pub(crate) requested_additional_permissions: bool, + pub(crate) requested_network_access: bool, + pub(crate) retry_count: u64, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolReviewToolKind { + CommandExecution, + FileChange, + McpToolCall, + Permissions, + NetworkAccess, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolReviewReviewer { + Guardian, + User, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolReviewTrigger { + Initial, + SandboxRetry, + NetworkRetry, + SubcommandExecve, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolReviewStatus { + Approved, + ApprovedForSession, + ApprovedExecpolicyAmendment, + NetworkPolicyAllow, + NetworkPolicyDeny, + Denied, + Aborted, + TimedOut, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallReviewEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) item_id: Option, + pub(crate) review_id: String, + pub(crate) thread_source: Option<&'static str>, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) tool_kind: ToolReviewToolKind, + pub(crate) tool_name: String, + pub(crate) reviewer: ToolReviewReviewer, + pub(crate) trigger: ToolReviewTrigger, + pub(crate) status: ToolReviewStatus, + pub(crate) created_at: u64, + pub(crate) completed_at: Option, + pub(crate) duration_ms: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallReviewEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexToolCallReviewEventParams, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CommandExecutionFamily { + Shell, + UserShell, + UnifiedExec, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CommandExecutionSource { + Agent, + UserShell, + UnifiedExecStartup, + UnifiedExecInteraction, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum WebSearchActionKind { + Search, + OpenPage, + FindInPage, + Other, +} + +#[derive(Serialize)] +pub(crate) struct CodexCommandExecutionEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) command_execution_source: CommandExecutionSource, + pub(crate) command_execution_family: CommandExecutionFamily, + pub(crate) exit_code: Option, + pub(crate) command_action_count: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexCommandExecutionEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexCommandExecutionEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexFileChangeEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) file_change_count: u64, + pub(crate) file_add_count: u64, + pub(crate) file_update_count: u64, + pub(crate) file_delete_count: u64, + pub(crate) file_move_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexFileChangeEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexFileChangeEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexMcpToolCallEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) mcp_server_name: String, + pub(crate) mcp_tool_name: String, + pub(crate) mcp_error_present: bool, + pub(crate) mcp_error_code: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexMcpToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexMcpToolCallEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexDynamicToolCallEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) dynamic_tool_name: String, + pub(crate) success: Option, + pub(crate) output_content_item_count: Option, + pub(crate) output_text_item_count: Option, + pub(crate) output_image_item_count: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexDynamicToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexDynamicToolCallEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexCollabAgentToolCallEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) sender_thread_id: String, + pub(crate) receiver_thread_count: u64, + pub(crate) receiver_thread_ids: Option>, + pub(crate) requested_model: Option, + pub(crate) requested_reasoning_effort: Option, + pub(crate) agent_state_count: Option, + pub(crate) completed_agent_count: Option, + pub(crate) failed_agent_count: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexCollabAgentToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexCollabAgentToolCallEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexWebSearchEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) web_search_action: Option, + pub(crate) query_present: bool, + pub(crate) query_count: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexWebSearchEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexWebSearchEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexImageGenerationEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) image_generation_status: String, + pub(crate) revised_prompt_present: bool, + pub(crate) saved_path_present: bool, +} + +#[derive(Serialize)] +pub(crate) struct CodexImageGenerationEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexImageGenerationEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexAppMetadata { pub(crate) connector_id: Option, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index ed0f1036ca10..2fb23199cb64 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -51,3 +51,27 @@ pub fn now_unix_seconds() -> u64 { .unwrap_or_default() .as_secs() } + +pub fn now_unix_millis() -> u64 { + u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + ) + .unwrap_or(u64::MAX) +} + +pub(crate) fn serialize_enum_as_string(value: &T) -> Option { + serde_json::to_value(value) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) +} + +pub(crate) fn usize_to_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +pub(crate) fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 43da35c47c3e..90ad1d9fbeb5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2,15 +2,32 @@ use crate::events::AppServerRpcTransport; use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexCollabAgentToolCallEventParams; +use crate::events::CodexCollabAgentToolCallEventRequest; +use crate::events::CodexCommandExecutionEventParams; +use crate::events::CodexCommandExecutionEventRequest; use crate::events::CodexCompactionEventRequest; +use crate::events::CodexDynamicToolCallEventParams; +use crate::events::CodexDynamicToolCallEventRequest; +use crate::events::CodexFileChangeEventParams; +use crate::events::CodexFileChangeEventRequest; use crate::events::CodexHookRunEventRequest; +use crate::events::CodexImageGenerationEventParams; +use crate::events::CodexImageGenerationEventRequest; +use crate::events::CodexMcpToolCallEventParams; +use crate::events::CodexMcpToolCallEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolItemEventBase; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventParams; use crate::events::CodexTurnSteerEventRequest; +use crate::events::CodexWebSearchEventParams; +use crate::events::CodexWebSearchEventRequest; +use crate::events::CommandExecutionFamily; +use crate::events::CommandExecutionSource; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; use crate::events::GuardianReviewEventRequest; @@ -18,7 +35,11 @@ use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolItemFailureKind; +use crate::events::ToolItemFinalApprovalOutcome; +use crate::events::ToolItemTerminalStatus; use crate::events::TrackEventRequest; +use crate::events::WebSearchActionKind; use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_hook_run_metadata; @@ -47,14 +68,29 @@ use crate::facts::TurnSteerRejectionReason; use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use crate::now_unix_seconds; +use crate::option_i64_to_u64; +use crate::serialize_enum_as_string; +use crate::usize_to_u64; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CollabAgentStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource as AppServerCommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::DynamicToolCallOutputContentItem; +use codex_app_server_protocol::DynamicToolCallStatus; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::PatchChangeKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; +use codex_app_server_protocol::WebSearchAction; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -74,8 +110,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, - thread_connections: HashMap, - thread_metadata: HashMap, + threads: HashMap, } struct ConnectionState { @@ -83,6 +118,82 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +#[derive(Default)] +struct ThreadAnalyticsState { + connection_id: Option, + metadata: Option, +} + +#[derive(Clone, Copy)] +struct AnalyticsDropSite<'a> { + event_name: &'static str, + thread_id: &'a str, + turn_id: Option<&'a str>, + review_id: Option<&'a str>, + item_id: Option<&'a str>, +} + +impl<'a> AnalyticsDropSite<'a> { + fn guardian(input: &'a GuardianReviewEventParams) -> Self { + Self { + event_name: "guardian", + thread_id: &input.thread_id, + turn_id: Some(&input.turn_id), + review_id: Some(&input.review_id), + item_id: None, + } + } + + fn compaction(input: &'a CodexCompactionEvent) -> Self { + Self { + event_name: "compaction", + thread_id: &input.thread_id, + turn_id: Some(&input.turn_id), + review_id: None, + item_id: None, + } + } + + fn tool_item( + notification: &'a codex_app_server_protocol::ItemCompletedNotification, + item_id: &'a str, + ) -> Self { + Self { + event_name: "tool item", + thread_id: ¬ification.thread_id, + turn_id: Some(¬ification.turn_id), + review_id: None, + item_id: Some(item_id), + } + } + + fn turn_steer(thread_id: &'a str) -> Self { + Self { + event_name: "turn steer", + thread_id, + turn_id: None, + review_id: None, + item_id: None, + } + } + + fn turn(thread_id: &'a str, turn_id: &'a str) -> Self { + Self { + event_name: "turn", + thread_id, + turn_id: Some(turn_id), + review_id: None, + item_id: None, + } + } +} + +enum MissingAnalyticsContext { + ThreadConnection, + Connection { connection_id: u64 }, + ThreadMetadata, +} + #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, @@ -144,7 +255,6 @@ struct CompletedTurnState { } struct TurnState { - connection_id: Option, thread_id: Option, num_input_images: Option, resolved_config: Option, @@ -274,6 +384,26 @@ impl AnalyticsReducer { input: SubAgentThreadStartedInput, out: &mut Vec, ) { + let parent_thread_id = input + .parent_thread_id + .clone() + .or_else(|| subagent_parent_thread_id(&input.subagent_source)); + let parent_connection_id = parent_thread_id + .as_ref() + .and_then(|parent_thread_id| self.threads.get(parent_thread_id)) + .and_then(|thread| thread.connection_id); + let thread_state = self.threads.entry(input.thread_id.clone()).or_default(); + thread_state + .metadata + .get_or_insert_with(|| ThreadMetadataState { + thread_source: Some("subagent"), + initialization_mode: ThreadInitializationMode::New, + subagent_source: Some(subagent_source_name(&input.subagent_source)), + parent_thread_id, + }); + if thread_state.connection_id.is_none() { + thread_state.connection_id = parent_connection_id; + } out.push(TrackEventRequest::ThreadInitialized( subagent_thread_started_event_request(input), )); @@ -284,23 +414,9 @@ impl AnalyticsReducer { input: GuardianReviewEventParams, out: &mut Vec, ) { - let Some(connection_id) = self.thread_connections.get(&input.thread_id) else { - tracing::warn!( - thread_id = %input.thread_id, - turn_id = %input.turn_id, - review_id = %input.review_id, - "dropping guardian analytics event: missing thread connection metadata" - ); - return; - }; - let Some(connection_state) = self.connections.get(connection_id) else { - tracing::warn!( - thread_id = %input.thread_id, - turn_id = %input.turn_id, - review_id = %input.review_id, - connection_id, - "dropping guardian analytics event: missing connection metadata" - ); + let Some(connection_state) = + self.thread_connection_or_warn(AnalyticsDropSite::guardian(&input)) + else { return; }; out.push(TrackEventRequest::GuardianReview(Box::new( @@ -355,7 +471,6 @@ impl AnalyticsReducer { let thread_id = input.thread_id.clone(); let num_input_images = input.num_input_images; let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { - connection_id: None, thread_id: None, num_input_images: None, resolved_config: None, @@ -377,7 +492,6 @@ impl AnalyticsReducer { ) { let turn_id = input.turn_id.clone(); let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { - connection_id: None, thread_id: None, num_input_images: None, resolved_config: None, @@ -538,7 +652,6 @@ impl AnalyticsReducer { return; }; let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { - connection_id: None, thread_id: None, num_input_images: None, resolved_config: None, @@ -547,7 +660,6 @@ impl AnalyticsReducer { completed: None, steer_count: 0, }); - turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); turn_state.num_input_images = Some(pending_request.num_input_images); self.maybe_emit_turn_event(&turn_id, out); @@ -597,13 +709,12 @@ impl AnalyticsReducer { fn ingest_turn_steer_error_response( &mut self, - connection_id: u64, + _connection_id: u64, pending_request: PendingTurnSteerState, error_type: Option, out: &mut Vec, ) { self.emit_turn_steer_event( - connection_id, pending_request, /*accepted_turn_id*/ None, TurnSteerResult::Rejected, @@ -618,9 +729,27 @@ impl AnalyticsReducer { out: &mut Vec, ) { match notification { + ServerNotification::ItemCompleted(notification) => { + let Some(item_id) = tool_item_id(¬ification.item) else { + return; + }; + let Some((connection_state, thread_metadata)) = self + .thread_context_or_warn(AnalyticsDropSite::tool_item(¬ification, item_id)) + else { + return; + }; + if let Some(event) = tool_item_event( + ¬ification.thread_id, + ¬ification.turn_id, + ¬ification.item, + connection_state, + thread_metadata, + ) { + out.push(event); + } + } ServerNotification::TurnStarted(notification) => { let turn_state = self.turns.entry(notification.turn.id).or_insert(TurnState { - connection_id: None, thread_id: None, num_input_images: None, resolved_config: None, @@ -639,7 +768,6 @@ impl AnalyticsReducer { self.turns .entry(notification.turn.id.clone()) .or_insert(TurnState { - connection_id: None, thread_id: None, num_input_images: None, resolved_config: None, @@ -686,10 +814,13 @@ impl AnalyticsReducer { }; let thread_metadata = ThreadMetadataState::from_thread_metadata(&thread_source, initialization_mode); - self.thread_connections - .insert(thread_id.clone(), connection_id); - self.thread_metadata - .insert(thread_id.clone(), thread_metadata.clone()); + self.threads.insert( + thread_id.clone(), + ThreadAnalyticsState { + connection_id: Some(connection_id), + metadata: Some(thread_metadata.clone()), + }, + ); out.push(TrackEventRequest::ThreadInitialized( ThreadInitializedEvent { event_type: "codex_thread_initialized", @@ -701,8 +832,8 @@ impl AnalyticsReducer { ephemeral: thread.ephemeral, thread_source: thread_metadata.thread_source, initialization_mode, - subagent_source: thread_metadata.subagent_source, - parent_thread_id: thread_metadata.parent_thread_id, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), created_at: u64::try_from(thread.created_at).unwrap_or_default(), }, }, @@ -710,29 +841,9 @@ impl AnalyticsReducer { } fn ingest_compaction(&mut self, input: CodexCompactionEvent, out: &mut Vec) { - let Some(connection_id) = self.thread_connections.get(&input.thread_id) else { - tracing::warn!( - thread_id = %input.thread_id, - turn_id = %input.turn_id, - "dropping compaction analytics event: missing thread connection metadata" - ); - return; - }; - let Some(connection_state) = self.connections.get(connection_id) else { - tracing::warn!( - thread_id = %input.thread_id, - turn_id = %input.turn_id, - connection_id, - "dropping compaction analytics event: missing connection metadata" - ); - return; - }; - let Some(thread_metadata) = self.thread_metadata.get(&input.thread_id) else { - tracing::warn!( - thread_id = %input.thread_id, - turn_id = %input.turn_id, - "dropping compaction analytics event: missing thread lifecycle metadata" - ); + let Some((connection_state, thread_metadata)) = + self.thread_context_or_warn(AnalyticsDropSite::compaction(&input)) + else { return; }; out.push(TrackEventRequest::Compaction(Box::new( @@ -766,7 +877,6 @@ impl AnalyticsReducer { turn_state.steer_count += 1; } self.emit_turn_steer_event( - connection_id, pending_request, Some(response.turn_id), TurnSteerResult::Accepted, @@ -777,21 +887,15 @@ impl AnalyticsReducer { fn emit_turn_steer_event( &mut self, - connection_id: u64, pending_request: PendingTurnSteerState, accepted_turn_id: Option, result: TurnSteerResult, rejection_reason: Option, out: &mut Vec, ) { - let Some(connection_state) = self.connections.get(&connection_id) else { - return; - }; - let Some(thread_metadata) = self.thread_metadata.get(&pending_request.thread_id) else { - tracing::warn!( - thread_id = %pending_request.thread_id, - "dropping turn steer analytics event: missing thread lifecycle metadata" - ); + let Some((connection_state, thread_metadata)) = + self.thread_context_or_warn(AnalyticsDropSite::turn_steer(&pending_request.thread_id)) + else { return; }; out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { @@ -824,42 +928,20 @@ impl AnalyticsReducer { { return; } - let connection_metadata = turn_state - .connection_id - .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| { - ( - connection_state.app_server_client.clone(), - connection_state.runtime.clone(), - ) - }); - let Some((app_server_client, runtime)) = connection_metadata else { - if let Some(connection_id) = turn_state.connection_id { - tracing::warn!( - turn_id, - connection_id, - "dropping turn analytics event: missing connection metadata" - ); - } - return; - }; let Some(thread_id) = turn_state.thread_id.as_ref() else { return; }; - let Some(thread_metadata) = self.thread_metadata.get(thread_id) else { - tracing::warn!( - thread_id, - turn_id, - "dropping turn analytics event: missing thread lifecycle metadata" - ); + let Some((connection_state, thread_metadata)) = + self.thread_context_or_warn(AnalyticsDropSite::turn(thread_id, turn_id)) + else { return; }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - app_server_client, - runtime, + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), turn_id.to_string(), turn_state, thread_metadata, @@ -868,6 +950,679 @@ impl AnalyticsReducer { ))); self.turns.remove(turn_id); } + + fn thread_connection_or_warn( + &self, + drop_site: AnalyticsDropSite<'_>, + ) -> Option<&ConnectionState> { + let connection_id = self.thread_connection_id_or_warn(&drop_site)?; + let Some(connection_state) = self.connections.get(&connection_id) else { + warn_missing_analytics_context( + &drop_site, + MissingAnalyticsContext::Connection { connection_id }, + ); + return None; + }; + Some(connection_state) + } + + fn thread_context_or_warn( + &self, + drop_site: AnalyticsDropSite<'_>, + ) -> Option<(&ConnectionState, &ThreadMetadataState)> { + let connection_state = self.thread_connection_or_warn(drop_site)?; + let Some(thread_metadata) = self.thread_metadata(drop_site.thread_id) else { + warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata); + return None; + }; + Some((connection_state, thread_metadata)) + } + + fn thread_connection_id_or_warn(&self, drop_site: &AnalyticsDropSite<'_>) -> Option { + let Some(thread_state) = self.threads.get(drop_site.thread_id) else { + warn_missing_analytics_context(drop_site, MissingAnalyticsContext::ThreadConnection); + return None; + }; + let Some(connection_id) = thread_state.connection_id else { + warn_missing_analytics_context(drop_site, MissingAnalyticsContext::ThreadConnection); + return None; + }; + Some(connection_id) + } + + fn thread_metadata(&self, thread_id: &str) -> Option<&ThreadMetadataState> { + self.threads + .get(thread_id) + .and_then(|thread| thread.metadata.as_ref()) + } +} + +fn warn_missing_analytics_context( + drop_site: &AnalyticsDropSite<'_>, + missing: MissingAnalyticsContext, +) { + match missing { + MissingAnalyticsContext::ThreadConnection => { + tracing::warn!( + thread_id = %drop_site.thread_id, + turn_id = ?drop_site.turn_id, + review_id = ?drop_site.review_id, + item_id = ?drop_site.item_id, + "dropping {} analytics event: missing thread connection metadata", + drop_site.event_name + ); + } + MissingAnalyticsContext::Connection { connection_id } => { + tracing::warn!( + thread_id = %drop_site.thread_id, + turn_id = ?drop_site.turn_id, + review_id = ?drop_site.review_id, + item_id = ?drop_site.item_id, + connection_id, + "dropping {} analytics event: missing connection metadata", + drop_site.event_name + ); + } + MissingAnalyticsContext::ThreadMetadata => { + tracing::warn!( + thread_id = %drop_site.thread_id, + turn_id = ?drop_site.turn_id, + review_id = ?drop_site.review_id, + item_id = ?drop_site.item_id, + "dropping {} analytics event: missing thread lifecycle metadata", + drop_site.event_name + ); + } + } +} + +fn tool_item_id(item: &ThreadItem) -> Option<&str> { + match item { + ThreadItem::CommandExecution { id, .. } + | ThreadItem::FileChange { id, .. } + | ThreadItem::McpToolCall { id, .. } + | ThreadItem::DynamicToolCall { id, .. } + | ThreadItem::CollabAgentToolCall { id, .. } + | ThreadItem::WebSearch { id, .. } + | ThreadItem::ImageGeneration { id, .. } => Some(id), + _ => None, + } +} + +fn tool_item_event( + thread_id: &str, + turn_id: &str, + item: &ThreadItem, + connection_state: &ConnectionState, + thread_metadata: &ThreadMetadataState, +) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + command_actions, + exit_code, + started_at_ms, + completed_at_ms, + duration_ms, + .. + } => { + let started_at_ms = option_i64_to_u64(*started_at_ms)?; + let completed_at_ms = option_i64_to_u64(*completed_at_ms)?; + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + command_execution_tool_name(*source).to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + duration_ms: option_i64_to_u64(*duration_ms), + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::CommandExecution( + CodexCommandExecutionEventRequest { + event_type: "codex_command_execution_event", + event_params: CodexCommandExecutionEventParams { + base, + command_execution_source: (*source).into(), + command_execution_family: command_execution_family(*source), + exit_code: *exit_code, + command_action_count: Some(usize_to_u64(command_actions.len())), + }, + }, + )) + } + ThreadItem::FileChange { + id, + changes, + status, + started_at_ms, + completed_at_ms, + duration_ms, + } => { + let started_at_ms = option_i64_to_u64(*started_at_ms)?; + let completed_at_ms = option_i64_to_u64(*completed_at_ms)?; + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + let counts = file_change_counts(changes); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + "apply_patch".to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + duration_ms: option_i64_to_u64(*duration_ms), + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::FileChange(CodexFileChangeEventRequest { + event_type: "codex_file_change_event", + event_params: CodexFileChangeEventParams { + base, + file_change_count: usize_to_u64(changes.len()), + file_add_count: counts.add, + file_update_count: counts.update, + file_delete_count: counts.delete, + file_move_count: counts.move_, + }, + })) + } + ThreadItem::McpToolCall { + id, + server, + tool, + status, + error, + started_at_ms, + completed_at_ms, + duration_ms, + .. + } => { + let started_at_ms = option_i64_to_u64(*started_at_ms)?; + let completed_at_ms = option_i64_to_u64(*completed_at_ms)?; + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + tool.clone(), + ToolItemOutcome { + terminal_status, + failure_kind, + duration_ms: option_i64_to_u64(*duration_ms), + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::McpToolCall( + CodexMcpToolCallEventRequest { + event_type: "codex_mcp_tool_call_event", + event_params: CodexMcpToolCallEventParams { + base, + mcp_server_name: server.clone(), + mcp_tool_name: tool.clone(), + mcp_error_present: error.is_some(), + mcp_error_code: None, + }, + }, + )) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + content_items, + success, + started_at_ms, + completed_at_ms, + duration_ms, + .. + } => { + let started_at_ms = option_i64_to_u64(*started_at_ms)?; + let completed_at_ms = option_i64_to_u64(*completed_at_ms)?; + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + let counts = content_items + .as_ref() + .map(|items| dynamic_content_counts(items)); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + tool.clone(), + ToolItemOutcome { + terminal_status, + failure_kind, + duration_ms: option_i64_to_u64(*duration_ms), + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::DynamicToolCall( + CodexDynamicToolCallEventRequest { + event_type: "codex_dynamic_tool_call_event", + event_params: CodexDynamicToolCallEventParams { + base, + dynamic_tool_name: tool.clone(), + success: *success, + output_content_item_count: counts.map(|counts| counts.total), + output_text_item_count: counts.map(|counts| counts.text), + output_image_item_count: counts.map(|counts| counts.image), + }, + }, + )) + } + ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + model, + reasoning_effort, + agents_states, + started_at_ms, + completed_at_ms, + duration_ms, + .. + } => { + let started_at_ms = option_i64_to_u64(*started_at_ms)?; + let completed_at_ms = option_i64_to_u64(*completed_at_ms)?; + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + collab_agent_tool_name(tool).to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + duration_ms: option_i64_to_u64(*duration_ms), + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::CollabAgentToolCall( + CodexCollabAgentToolCallEventRequest { + event_type: "codex_collab_agent_tool_call_event", + event_params: CodexCollabAgentToolCallEventParams { + base, + sender_thread_id: sender_thread_id.clone(), + receiver_thread_count: usize_to_u64(receiver_thread_ids.len()), + receiver_thread_ids: Some(receiver_thread_ids.clone()), + requested_model: model.clone(), + requested_reasoning_effort: reasoning_effort + .as_ref() + .and_then(serialize_enum_as_string), + agent_state_count: Some(usize_to_u64(agents_states.len())), + completed_agent_count: Some(usize_to_u64( + agents_states + .values() + .filter(|state| state.status == CollabAgentStatus::Completed) + .count(), + )), + failed_agent_count: Some(usize_to_u64( + agents_states + .values() + .filter(|state| { + matches!( + state.status, + CollabAgentStatus::Errored + | CollabAgentStatus::Shutdown + | CollabAgentStatus::NotFound + ) + }) + .count(), + )), + }, + }, + )) + } + ThreadItem::WebSearch { + id, + query, + action, + started_at_ms, + completed_at_ms, + duration_ms, + } => { + let started_at_ms = option_i64_to_u64(*started_at_ms)?; + let completed_at_ms = option_i64_to_u64(*completed_at_ms)?; + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + "web_search".to_string(), + ToolItemOutcome { + terminal_status: ToolItemTerminalStatus::Completed, + failure_kind: None, + duration_ms: option_i64_to_u64(*duration_ms), + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::WebSearch(CodexWebSearchEventRequest { + event_type: "codex_web_search_event", + event_params: CodexWebSearchEventParams { + base, + web_search_action: action.as_ref().map(web_search_action_kind), + query_present: !query.trim().is_empty(), + query_count: web_search_query_count(query, action.as_ref()), + }, + })) + } + ThreadItem::ImageGeneration { + id, + status, + revised_prompt, + saved_path, + started_at_ms, + completed_at_ms, + duration_ms, + .. + } => { + let started_at_ms = option_i64_to_u64(*started_at_ms)?; + let completed_at_ms = option_i64_to_u64(*completed_at_ms)?; + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + "image_generation".to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + duration_ms: option_i64_to_u64(*duration_ms), + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::ImageGeneration( + CodexImageGenerationEventRequest { + event_type: "codex_image_generation_event", + event_params: CodexImageGenerationEventParams { + base, + image_generation_status: status.clone(), + revised_prompt_present: revised_prompt.is_some(), + saved_path_present: saved_path.is_some(), + }, + }, + )) + } + _ => None, + } +} + +struct ToolItemOutcome { + terminal_status: ToolItemTerminalStatus, + failure_kind: Option, + duration_ms: Option, +} + +struct ToolItemContext<'a> { + started_at_ms: u64, + completed_at_ms: u64, + connection_state: &'a ConnectionState, + thread_metadata: &'a ThreadMetadataState, +} + +fn tool_item_base( + thread_id: &str, + turn_id: &str, + item_id: String, + tool_name: String, + outcome: ToolItemOutcome, + context: ToolItemContext<'_>, +) -> CodexToolItemEventBase { + let thread_metadata = context.thread_metadata; + CodexToolItemEventBase { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item_id, + app_server_client: context.connection_state.app_server_client.clone(), + runtime: context.connection_state.runtime.clone(), + thread_source: thread_metadata.thread_source, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + tool_name, + started_at_ms: context.started_at_ms, + completed_at_ms: Some(context.completed_at_ms), + duration_ms: outcome.duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_approval_outcome: ToolItemFinalApprovalOutcome::Unknown, + terminal_status: outcome.terminal_status, + failure_kind: outcome.failure_kind, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + } +} + +impl From for CommandExecutionSource { + fn from(source: AppServerCommandExecutionSource) -> Self { + match source { + AppServerCommandExecutionSource::Agent => Self::Agent, + AppServerCommandExecutionSource::UserShell => Self::UserShell, + AppServerCommandExecutionSource::UnifiedExecStartup => Self::UnifiedExecStartup, + AppServerCommandExecutionSource::UnifiedExecInteraction => Self::UnifiedExecInteraction, + } + } +} + +fn command_execution_family(source: AppServerCommandExecutionSource) -> CommandExecutionFamily { + match source { + AppServerCommandExecutionSource::Agent => CommandExecutionFamily::Shell, + AppServerCommandExecutionSource::UserShell => CommandExecutionFamily::UserShell, + AppServerCommandExecutionSource::UnifiedExecStartup + | AppServerCommandExecutionSource::UnifiedExecInteraction => { + CommandExecutionFamily::UnifiedExec + } + } +} + +fn command_execution_tool_name(source: AppServerCommandExecutionSource) -> &'static str { + match source { + AppServerCommandExecutionSource::UnifiedExecStartup + | AppServerCommandExecutionSource::UnifiedExecInteraction => "unified_exec", + AppServerCommandExecutionSource::UserShell => "user_shell", + AppServerCommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolItemTerminalStatus::Rejected, + Some(ToolItemFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolItemTerminalStatus::Rejected, + Some(ToolItemFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolItemTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + ), + _ => (ToolItemTerminalStatus::Completed, None), + } +} + +fn collab_agent_tool_name(tool: &CollabAgentTool) -> &'static str { + match tool { + CollabAgentTool::SpawnAgent => "spawn_agent", + CollabAgentTool::SendInput => "send_input", + CollabAgentTool::ResumeAgent => "resume_agent", + CollabAgentTool::Wait => "wait_agent", + CollabAgentTool::CloseAgent => "close_agent", + } +} + +#[derive(Default)] +struct FileChangeCounts { + add: u64, + update: u64, + delete: u64, + move_: u64, +} + +fn file_change_counts(changes: &[codex_app_server_protocol::FileUpdateChange]) -> FileChangeCounts { + let mut counts = FileChangeCounts::default(); + for change in changes { + match &change.kind { + PatchChangeKind::Add => counts.add += 1, + PatchChangeKind::Delete => counts.delete += 1, + PatchChangeKind::Update { move_path: Some(_) } => counts.move_ += 1, + PatchChangeKind::Update { move_path: None } => counts.update += 1, + } + } + counts +} + +#[derive(Clone, Copy)] +struct DynamicContentCounts { + total: u64, + text: u64, + image: u64, +} + +fn dynamic_content_counts(items: &[DynamicToolCallOutputContentItem]) -> DynamicContentCounts { + let mut text = 0; + let mut image = 0; + for item in items { + match item { + DynamicToolCallOutputContentItem::InputText { .. } => text += 1, + DynamicToolCallOutputContentItem::InputImage { .. } => image += 1, + } + } + DynamicContentCounts { + total: usize_to_u64(items.len()), + text, + image, + } +} + +fn web_search_action_kind(action: &WebSearchAction) -> WebSearchActionKind { + match action { + WebSearchAction::Search { .. } => WebSearchActionKind::Search, + WebSearchAction::OpenPage { .. } => WebSearchActionKind::OpenPage, + WebSearchAction::FindInPage { .. } => WebSearchActionKind::FindInPage, + WebSearchAction::Other => WebSearchActionKind::Other, + } +} + +fn web_search_query_count(query: &str, action: Option<&WebSearchAction>) -> Option { + match action { + Some(WebSearchAction::Search { query, queries }) => queries + .as_ref() + .map(|queries| usize_to_u64(queries.len())) + .or_else(|| query.as_ref().map(|_| 1)), + Some(WebSearchAction::OpenPage { .. }) + | Some(WebSearchAction::FindInPage { .. }) + | Some(WebSearchAction::Other) => None, + None => (!query.trim().is_empty()).then_some(1), + } } fn codex_turn_event_params( diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6dbc66cb418e..9bb06614dfe0 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3313,6 +3313,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -3355,6 +3363,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -3385,9 +3401,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -3411,6 +3451,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -3451,6 +3499,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -3479,6 +3535,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -3505,6 +3569,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -3544,6 +3616,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -3584,6 +3672,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -3632,12 +3728,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -3680,6 +3800,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -3702,6 +3838,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "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 9adc20bd3462..e40a8fc97be6 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 @@ -15479,6 +15479,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -15521,6 +15529,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/CommandExecutionStatus" }, @@ -15551,9 +15567,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/PatchApplyStatus" }, @@ -15577,6 +15617,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -15617,6 +15665,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/McpToolCallStatus" }, @@ -15645,6 +15701,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/v2/DynamicToolCallOutputContentItem" @@ -15671,6 +15735,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/DynamicToolCallStatus" }, @@ -15710,6 +15782,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -15750,6 +15838,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -15798,12 +15894,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -15846,6 +15966,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -15868,6 +16004,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "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 d581e19b98a0..027557228b9b 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 @@ -13365,6 +13365,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -13407,6 +13415,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -13437,9 +13453,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -13463,6 +13503,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -13503,6 +13551,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -13531,6 +13587,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -13557,6 +13621,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -13596,6 +13668,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -13636,6 +13724,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -13684,12 +13780,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -13732,6 +13852,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -13754,6 +13890,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "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..df86d4d4f2f3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -668,6 +668,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -710,6 +718,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -740,9 +756,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -766,6 +806,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -806,6 +854,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -834,6 +890,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -860,6 +924,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -899,6 +971,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -939,6 +1027,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -987,12 +1083,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -1035,6 +1155,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -1057,6 +1193,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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..c9742a09b636 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -668,6 +668,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -710,6 +718,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -740,9 +756,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -766,6 +806,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -806,6 +854,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -834,6 +890,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -860,6 +924,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -899,6 +971,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -939,6 +1027,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -987,12 +1083,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -1035,6 +1155,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -1057,6 +1193,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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 16abcd7806a5..75995b7e4df2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -812,6 +812,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -854,6 +862,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -884,9 +900,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -910,6 +950,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -950,6 +998,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -978,6 +1034,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1004,6 +1068,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1043,6 +1115,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1083,6 +1171,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1131,12 +1227,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -1179,6 +1299,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -1201,6 +1337,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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 653c5f238773..2326d8c0ae3a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1638,6 +1638,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -1680,6 +1688,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -1710,9 +1726,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -1736,6 +1776,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -1776,6 +1824,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -1804,6 +1860,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1830,6 +1894,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1869,6 +1941,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1909,6 +1997,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1957,12 +2053,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -2005,6 +2125,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -2027,6 +2163,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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 2f5cbb95002d..07c1b149e580 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1088,6 +1088,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -1130,6 +1138,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -1160,9 +1176,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -1186,6 +1226,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -1226,6 +1274,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -1254,6 +1310,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1280,6 +1344,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1319,6 +1391,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1359,6 +1447,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1407,12 +1503,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -1455,6 +1575,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -1477,6 +1613,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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 b9ae59708aa6..e608cf06be8d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1088,6 +1088,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -1130,6 +1138,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -1160,9 +1176,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -1186,6 +1226,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -1226,6 +1274,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -1254,6 +1310,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1280,6 +1344,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1319,6 +1391,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1359,6 +1447,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1407,12 +1503,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -1455,6 +1575,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -1477,6 +1613,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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 cda474c2947b..6ac2584e96a2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1088,6 +1088,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -1130,6 +1138,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -1160,9 +1176,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -1186,6 +1226,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -1226,6 +1274,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -1254,6 +1310,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1280,6 +1344,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1319,6 +1391,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1359,6 +1447,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1407,12 +1503,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -1455,6 +1575,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -1477,6 +1613,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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 27cf47f2fc58..56670abf3a13 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1638,6 +1638,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -1680,6 +1688,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -1710,9 +1726,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -1736,6 +1776,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -1776,6 +1824,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -1804,6 +1860,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1830,6 +1894,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1869,6 +1941,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1909,6 +1997,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1957,12 +2053,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -2005,6 +2125,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -2027,6 +2163,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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 e5339f4e996f..09793ab1b1e5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1088,6 +1088,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -1130,6 +1138,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -1160,9 +1176,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -1186,6 +1226,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -1226,6 +1274,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -1254,6 +1310,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1280,6 +1344,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1319,6 +1391,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1359,6 +1447,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1407,12 +1503,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -1455,6 +1575,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -1477,6 +1613,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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 7d93606aa43c..56a18998a521 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1638,6 +1638,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -1680,6 +1688,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -1710,9 +1726,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -1736,6 +1776,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -1776,6 +1824,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -1804,6 +1860,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1830,6 +1894,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1869,6 +1941,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1909,6 +1997,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1957,12 +2053,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -2005,6 +2125,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -2027,6 +2163,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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 774686e46ae2..ffdc4de1e884 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1088,6 +1088,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -1130,6 +1138,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -1160,9 +1176,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -1186,6 +1226,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -1226,6 +1274,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -1254,6 +1310,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1280,6 +1344,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1319,6 +1391,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1359,6 +1447,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1407,12 +1503,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -1455,6 +1575,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -1477,6 +1613,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json index eaa3becdea85..920c7c68acb0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -812,6 +812,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -854,6 +862,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -884,9 +900,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -910,6 +950,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -950,6 +998,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -978,6 +1034,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1004,6 +1068,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1043,6 +1115,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1083,6 +1171,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1131,12 +1227,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -1179,6 +1299,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -1201,6 +1337,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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 64179af7e1f0..0ee4af9c752d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1088,6 +1088,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -1130,6 +1138,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -1160,9 +1176,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -1186,6 +1226,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -1226,6 +1274,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -1254,6 +1310,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1280,6 +1344,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1319,6 +1391,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1359,6 +1447,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1407,12 +1503,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -1455,6 +1575,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -1477,6 +1613,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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 0739fa31bc48..c91aedce9efa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -812,6 +812,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -854,6 +862,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -884,9 +900,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -910,6 +950,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -950,6 +998,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -978,6 +1034,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1004,6 +1068,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1043,6 +1115,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1083,6 +1171,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1131,12 +1227,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -1179,6 +1299,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -1201,6 +1337,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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 bc5917ef15a7..12e5340ef551 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -812,6 +812,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -854,6 +862,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -884,9 +900,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -910,6 +950,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -950,6 +998,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -978,6 +1034,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1004,6 +1068,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1043,6 +1115,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1083,6 +1171,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1131,12 +1227,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -1179,6 +1299,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -1201,6 +1337,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, 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 22ad85d906dc..01d3a232d810 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -812,6 +812,14 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "cwd": { "allOf": [ { @@ -854,6 +862,14 @@ ], "default": "agent" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when command execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -884,9 +900,33 @@ }, "type": "array" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of patch application in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when patch application started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/PatchApplyStatus" }, @@ -910,6 +950,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", "format": "int64", @@ -950,6 +998,14 @@ "server": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when MCP tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/McpToolCallStatus" }, @@ -978,6 +1034,14 @@ { "properties": { "arguments": true, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "contentItems": { "items": { "$ref": "#/definitions/DynamicToolCallOutputContentItem" @@ -1004,6 +1068,14 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when dynamic tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, @@ -1043,6 +1115,22 @@ "description": "Last known status of the target agents, when available.", "type": "object" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the collab tool execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "description": "Unique identifier for this collab tool call.", "type": "string" @@ -1083,6 +1171,14 @@ "description": "Thread ID of the agent issuing the collab request.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when collab tool execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "allOf": [ { @@ -1131,12 +1227,36 @@ } ] }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of the web search execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, "query": { "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when web search execution started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "webSearch" @@ -1179,6 +1299,22 @@ }, { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation completed, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "The duration of image generation in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "id": { "type": "string" }, @@ -1201,6 +1337,14 @@ } ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when image generation started, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index f7880c9d32ca..cd45b4b34a69 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -50,14 +50,50 @@ aggregatedOutput: string | null, * The command's exit code. */ exitCode: number | null, +/** + * Unix timestamp (in milliseconds) when command execution started, if known. + */ +startedAtMs: number | null, +/** + * Unix timestamp (in milliseconds) when command execution completed, if known. + */ +completedAtMs: number | null, /** * The duration of the command execution in milliseconds. */ -durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, result: McpToolCallResult | null, error: McpToolCallError | null, +durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array, status: PatchApplyStatus, +/** + * Unix timestamp (in milliseconds) when patch application started, if known. + */ +startedAtMs: number | null, +/** + * Unix timestamp (in milliseconds) when patch application completed, if known. + */ +completedAtMs: number | null, +/** + * The duration of patch application in milliseconds. + */ +durationMs: number | null, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, result: McpToolCallResult | null, error: McpToolCallError | null, +/** + * Unix timestamp (in milliseconds) when MCP tool execution started, if known. + */ +startedAtMs: number | null, +/** + * Unix timestamp (in milliseconds) when MCP tool execution completed, if known. + */ +completedAtMs: number | null, /** * The duration of the MCP tool call in milliseconds. */ durationMs: number | null, } | { "type": "dynamicToolCall", id: string, namespace: string | null, tool: string, arguments: JsonValue, status: DynamicToolCallStatus, contentItems: Array | null, success: boolean | null, +/** + * Unix timestamp (in milliseconds) when dynamic tool execution started, if known. + */ +startedAtMs: number | null, +/** + * Unix timestamp (in milliseconds) when dynamic tool execution completed, if known. + */ +completedAtMs: number | null, /** * The duration of the dynamic tool call in milliseconds. */ @@ -98,4 +134,40 @@ reasoningEffort: ReasoningEffort | null, /** * Last known status of the target agents, when available. */ -agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: AbsolutePathBuf, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; +agentsStates: { [key in string]?: CollabAgentState }, +/** + * Unix timestamp (in milliseconds) when collab tool execution started, if known. + */ +startedAtMs: number | null, +/** + * Unix timestamp (in milliseconds) when collab tool execution completed, if known. + */ +completedAtMs: number | null, +/** + * The duration of the collab tool execution in milliseconds. + */ +durationMs: number | null, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, +/** + * Unix timestamp (in milliseconds) when web search execution started, if known. + */ +startedAtMs: number | null, +/** + * Unix timestamp (in milliseconds) when web search execution completed, if known. + */ +completedAtMs: number | null, +/** + * The duration of the web search execution in milliseconds. + */ +durationMs: number | null, } | { "type": "imageView", id: string, path: AbsolutePathBuf, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, +/** + * Unix timestamp (in milliseconds) when image generation started, if known. + */ +startedAtMs: number | null, +/** + * Unix timestamp (in milliseconds) when image generation completed, if known. + */ +completedAtMs: number | null, +/** + * The duration of image generation in milliseconds. + */ +durationMs: number | null, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 546fb1b6796a..461e315b7315 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -45,6 +45,9 @@ pub fn build_file_change_approval_request_item( id: payload.call_id.clone(), changes: convert_patch_changes(&payload.changes), status: PatchApplyStatus::InProgress, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, } } @@ -53,6 +56,9 @@ pub fn build_file_change_begin_item(payload: &PatchApplyBeginEvent) -> ThreadIte id: payload.call_id.clone(), changes: convert_patch_changes(&payload.changes), status: PatchApplyStatus::InProgress, + started_at_ms: payload.started_at_ms, + completed_at_ms: None, + duration_ms: None, } } @@ -61,6 +67,9 @@ pub fn build_file_change_end_item(payload: &PatchApplyEndEvent) -> ThreadItem { id: payload.call_id.clone(), changes: convert_patch_changes(&payload.changes), status: (&payload.status).into(), + started_at_ms: payload.started_at_ms, + completed_at_ms: payload.completed_at_ms, + duration_ms: payload.duration_ms, } } @@ -82,6 +91,8 @@ pub fn build_command_execution_approval_request_item( .collect(), aggregated_output: None, exit_code: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, } } @@ -102,6 +113,8 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th .collect(), aggregated_output: None, exit_code: None, + started_at_ms: payload.started_at_ms, + completed_at_ms: None, duration_ms: None, } } @@ -129,6 +142,8 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread .collect(), aggregated_output, exit_code: Some(payload.exit_code), + started_at_ms: payload.started_at_ms, + completed_at_ms: payload.completed_at_ms, duration_ms: Some(duration_ms), } } @@ -158,6 +173,8 @@ pub fn build_item_from_guardian_event( command_actions, aggregated_output: None, exit_code: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, }) } @@ -194,6 +211,8 @@ pub fn build_item_from_guardian_event( command_actions, aggregated_output: None, exit_code: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, }) } 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 019c9fa83e32..2814101fb0a7 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -388,6 +388,9 @@ impl ThreadHistoryBuilder { id: payload.call_id.clone(), query: String::new(), action: None, + started_at_ms: payload.started_at_ms, + completed_at_ms: None, + duration_ms: None, }; self.upsert_item_in_current_turn(item); } @@ -397,6 +400,9 @@ impl ThreadHistoryBuilder { id: payload.call_id.clone(), query: payload.query.clone(), action: Some(WebSearchAction::from(payload.action.clone())), + started_at_ms: payload.started_at_ms, + completed_at_ms: payload.completed_at_ms, + duration_ms: payload.duration_ms, }; self.upsert_item_in_current_turn(item); } @@ -474,6 +480,8 @@ impl ThreadHistoryBuilder { status: DynamicToolCallStatus::InProgress, content_items: None, success: None, + started_at_ms: payload.started_at_ms, + completed_at_ms: None, duration_ms: None, }; if payload.turn_id.is_empty() { @@ -498,6 +506,8 @@ impl ThreadHistoryBuilder { status, content_items: Some(convert_dynamic_tool_content_items(&payload.content_items)), success: Some(payload.success), + started_at_ms: payload.started_at_ms, + completed_at_ms: payload.completed_at_ms, duration_ms, }; if payload.turn_id.is_empty() { @@ -521,6 +531,8 @@ impl ThreadHistoryBuilder { mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(), result: None, error: None, + started_at_ms: payload.started_at_ms, + completed_at_ms: None, duration_ms: None, }; self.upsert_item_in_current_turn(item); @@ -562,6 +574,8 @@ impl ThreadHistoryBuilder { mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(), result, error, + started_at_ms: payload.started_at_ms, + completed_at_ms: payload.completed_at_ms, duration_ms, }; self.upsert_item_in_current_turn(item); @@ -582,6 +596,9 @@ impl ThreadHistoryBuilder { revised_prompt: None, result: String::new(), saved_path: None, + started_at_ms: payload.started_at_ms, + completed_at_ms: None, + duration_ms: None, }; self.upsert_item_in_current_turn(item); } @@ -593,6 +610,9 @@ impl ThreadHistoryBuilder { revised_prompt: payload.revised_prompt.clone(), result: payload.result.clone(), saved_path: payload.saved_path.clone(), + started_at_ms: payload.started_at_ms, + completed_at_ms: payload.completed_at_ms, + duration_ms: payload.duration_ms, }; self.upsert_item_in_current_turn(item); } @@ -611,6 +631,9 @@ impl ThreadHistoryBuilder { model: Some(payload.model.clone()), reasoning_effort: Some(payload.reasoning_effort), agents_states: HashMap::new(), + started_at_ms: payload.started_at_ms, + completed_at_ms: None, + duration_ms: None, }; self.upsert_item_in_current_turn(item); } @@ -646,6 +669,9 @@ impl ThreadHistoryBuilder { model: Some(payload.model.clone()), reasoning_effort: Some(payload.reasoning_effort), agents_states, + started_at_ms: payload.started_at_ms, + completed_at_ms: payload.completed_at_ms, + duration_ms: payload.duration_ms, }); } @@ -663,6 +689,9 @@ impl ThreadHistoryBuilder { model: None, reasoning_effort: None, agents_states: HashMap::new(), + started_at_ms: payload.started_at_ms, + completed_at_ms: None, + duration_ms: None, }; self.upsert_item_in_current_turn(item); } @@ -687,6 +716,9 @@ impl ThreadHistoryBuilder { model: None, reasoning_effort: None, agents_states: [(receiver_id, received_status)].into_iter().collect(), + started_at_ms: payload.started_at_ms, + completed_at_ms: payload.completed_at_ms, + duration_ms: payload.duration_ms, }); } @@ -708,6 +740,9 @@ impl ThreadHistoryBuilder { model: None, reasoning_effort: None, agents_states: HashMap::new(), + started_at_ms: payload.started_at_ms, + completed_at_ms: None, + duration_ms: None, }; self.upsert_item_in_current_turn(item); } @@ -743,6 +778,9 @@ impl ThreadHistoryBuilder { model: None, reasoning_effort: None, agents_states, + started_at_ms: payload.started_at_ms, + completed_at_ms: payload.completed_at_ms, + duration_ms: payload.duration_ms, }); } @@ -760,6 +798,9 @@ impl ThreadHistoryBuilder { model: None, reasoning_effort: None, agents_states: HashMap::new(), + started_at_ms: payload.started_at_ms, + completed_at_ms: None, + duration_ms: None, }; self.upsert_item_in_current_turn(item); } @@ -786,6 +827,9 @@ impl ThreadHistoryBuilder { model: None, reasoning_effort: None, agents_states, + started_at_ms: payload.started_at_ms, + completed_at_ms: payload.completed_at_ms, + duration_ms: payload.duration_ms, }); } @@ -803,6 +847,9 @@ impl ThreadHistoryBuilder { model: None, reasoning_effort: None, agents_states: HashMap::new(), + started_at_ms: payload.started_at_ms, + completed_at_ms: None, + duration_ms: None, }; self.upsert_item_in_current_turn(item); } @@ -832,6 +879,9 @@ impl ThreadHistoryBuilder { model: None, reasoning_effort: None, agents_states, + started_at_ms: payload.started_at_ms, + completed_at_ms: payload.completed_at_ms, + duration_ms: payload.duration_ms, }); } @@ -1426,6 +1476,9 @@ mod tests { revised_prompt: Some("final prompt".into()), result: "Zm9v".into(), saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), @@ -1461,6 +1514,9 @@ mod tests { revised_prompt: Some("final prompt".into()), result: "Zm9v".into(), saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, ], } @@ -1810,6 +1866,9 @@ mod tests { query: Some("codex".into()), queries: None, }, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }), EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id: "exec-1".into(), @@ -1829,6 +1888,8 @@ mod tests { duration: Duration::from_millis(12), formatted_output: String::new(), status: CoreExecCommandStatus::Completed, + started_at_ms: None, + completed_at_ms: None, }), EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: "mcp-1".into(), @@ -1840,6 +1901,8 @@ mod tests { mcp_app_resource_uri: None, duration: Duration::from_millis(8), result: Err("boom".into()), + started_at_ms: None, + completed_at_ms: None, }), ]; @@ -1859,6 +1922,9 @@ mod tests { query: Some("codex".into()), queries: None, }), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, } ); assert_eq!( @@ -1875,6 +1941,8 @@ mod tests { }], aggregated_output: Some("hello world\n".into()), exit_code: Some(0), + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(12), } ); @@ -1891,6 +1959,8 @@ mod tests { error: Some(McpToolCallError { message: "boom".into(), }), + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(8), } ); @@ -1925,6 +1995,8 @@ mod tests { "ui/resourceUri": "ui://widget/lookup.html" })), }), + started_at_ms: None, + completed_at_ms: None, }), ]; @@ -1954,6 +2026,8 @@ mod tests { })), })), error: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(8), } ); @@ -1981,6 +2055,7 @@ mod tests { namespace: Some("codex_app".into()), tool: "lookup_ticket".into(), arguments: serde_json::json!({"id":"ABC-123"}), + started_at_ms: None, }, ), EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { @@ -1995,6 +2070,8 @@ mod tests { success: true, error: None, duration: Duration::from_millis(42), + started_at_ms: None, + completed_at_ms: None, }), ]; @@ -2017,6 +2094,8 @@ mod tests { text: "Ticket is open".into(), }]), success: Some(true), + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(42), } ); @@ -2053,6 +2132,8 @@ mod tests { duration: Duration::ZERO, formatted_output: String::new(), status: CoreExecCommandStatus::Declined, + started_at_ms: None, + completed_at_ms: None, }), EventMsg::PatchApplyEnd(PatchApplyEndEvent { call_id: "patch-declined".into(), @@ -2069,6 +2150,9 @@ mod tests { .into_iter() .collect(), status: CorePatchApplyStatus::Declined, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }), ]; @@ -2093,6 +2177,8 @@ mod tests { }], aggregated_output: Some("exec command rejected by user".into()), exit_code: Some(-1), + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(0), } ); @@ -2106,6 +2192,9 @@ mod tests { diff: "hello\n".into(), }], status: PatchApplyStatus::Declined, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, } ); } @@ -2184,6 +2273,8 @@ mod tests { }], aggregated_output: None, exit_code: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, } ); @@ -2245,6 +2336,8 @@ mod tests { }], aggregated_output: None, exit_code: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, } ); @@ -2302,6 +2395,8 @@ mod tests { duration: Duration::from_millis(5), formatted_output: "done\n".into(), status: CoreExecCommandStatus::Completed, + started_at_ms: None, + completed_at_ms: None, }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), @@ -2336,6 +2431,8 @@ mod tests { }], aggregated_output: Some("done\n".into()), exit_code: Some(0), + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(5), } ); @@ -2393,6 +2490,8 @@ mod tests { duration: Duration::from_millis(5), formatted_output: "done\n".into(), status: CoreExecCommandStatus::Completed, + started_at_ms: None, + completed_at_ms: None, }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), @@ -2454,6 +2553,7 @@ mod tests { )] .into_iter() .collect(), + started_at_ms: None, }), ]; @@ -2484,6 +2584,9 @@ mod tests { diff: "hello\n".into(), }], status: PatchApplyStatus::InProgress, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, ] ); @@ -2549,6 +2652,9 @@ mod tests { diff: "hello\n".into(), }], status: PatchApplyStatus::InProgress, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, ] ); @@ -2734,6 +2840,9 @@ mod tests { receiver_agent_nickname: None, receiver_agent_role: None, status: AgentStatus::Completed(None), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }), ]; @@ -2764,6 +2873,9 @@ mod tests { )] .into_iter() .collect(), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, } ); } @@ -2791,6 +2903,9 @@ mod tests { model: "gpt-5.4-mini".into(), reasoning_effort: codex_protocol::openai_models::ReasoningEffort::Medium, status: AgentStatus::Running, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }), ]; @@ -2821,6 +2936,9 @@ mod tests { )] .into_iter() .collect(), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, } ); } @@ -2847,6 +2965,7 @@ mod tests { sender_thread_id: sender, receiver_thread_id: receiver, prompt: "new task".into(), + started_at_ms: None, }, ), EventMsg::CollabAgentInteractionEnd( @@ -2858,6 +2977,9 @@ mod tests { receiver_agent_role: None, prompt: "new task".into(), status: AgentStatus::Interrupted, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, ), ]; @@ -2889,6 +3011,9 @@ mod tests { )] .into_iter() .collect(), + started_at_ms: None, + completed_at_ms: 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 f66078d4b5fe..2aa6cf5a3b1d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -5867,6 +5867,12 @@ pub enum ThreadItem { aggregated_output: Option, /// The command's exit code. exit_code: Option, + /// Unix timestamp (in milliseconds) when command execution started, if known. + #[ts(type = "number | null")] + started_at_ms: Option, + /// Unix timestamp (in milliseconds) when command execution completed, if known. + #[ts(type = "number | null")] + completed_at_ms: Option, /// The duration of the command execution in milliseconds. #[ts(type = "number | null")] duration_ms: Option, @@ -5877,6 +5883,15 @@ pub enum ThreadItem { id: String, changes: Vec, status: PatchApplyStatus, + /// Unix timestamp (in milliseconds) when patch application started, if known. + #[ts(type = "number | null")] + started_at_ms: Option, + /// Unix timestamp (in milliseconds) when patch application completed, if known. + #[ts(type = "number | null")] + completed_at_ms: Option, + /// The duration of patch application in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] @@ -5891,6 +5906,12 @@ pub enum ThreadItem { mcp_app_resource_uri: Option, result: Option>, error: Option, + /// Unix timestamp (in milliseconds) when MCP tool execution started, if known. + #[ts(type = "number | null")] + started_at_ms: Option, + /// Unix timestamp (in milliseconds) when MCP tool execution completed, if known. + #[ts(type = "number | null")] + completed_at_ms: Option, /// The duration of the MCP tool call in milliseconds. #[ts(type = "number | null")] duration_ms: Option, @@ -5905,6 +5926,12 @@ pub enum ThreadItem { status: DynamicToolCallStatus, content_items: Option>, success: Option, + /// Unix timestamp (in milliseconds) when dynamic tool execution started, if known. + #[ts(type = "number | null")] + started_at_ms: Option, + /// Unix timestamp (in milliseconds) when dynamic tool execution completed, if known. + #[ts(type = "number | null")] + completed_at_ms: Option, /// The duration of the dynamic tool call in milliseconds. #[ts(type = "number | null")] duration_ms: Option, @@ -5931,6 +5958,15 @@ pub enum ThreadItem { reasoning_effort: Option, /// Last known status of the target agents, when available. agents_states: HashMap, + /// Unix timestamp (in milliseconds) when collab tool execution started, if known. + #[ts(type = "number | null")] + started_at_ms: Option, + /// Unix timestamp (in milliseconds) when collab tool execution completed, if known. + #[ts(type = "number | null")] + completed_at_ms: Option, + /// The duration of the collab tool execution in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] @@ -5938,6 +5974,15 @@ pub enum ThreadItem { id: String, query: String, action: Option, + /// Unix timestamp (in milliseconds) when web search execution started, if known. + #[ts(type = "number | null")] + started_at_ms: Option, + /// Unix timestamp (in milliseconds) when web search execution completed, if known. + #[ts(type = "number | null")] + completed_at_ms: Option, + /// The duration of the web search execution in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] @@ -5952,6 +5997,15 @@ pub enum ThreadItem { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] saved_path: Option, + /// Unix timestamp (in milliseconds) when image generation started, if known. + #[ts(type = "number | null")] + started_at_ms: Option, + /// Unix timestamp (in milliseconds) when image generation completed, if known. + #[ts(type = "number | null")] + completed_at_ms: Option, + /// The duration of image generation in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] @@ -6416,6 +6470,9 @@ impl From for ThreadItem { id: search.id, query: search.query, action: Some(WebSearchAction::from(search.action)), + started_at_ms: search.started_at_ms, + completed_at_ms: search.completed_at_ms, + duration_ms: search.duration_ms, }, CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration { id: image.id, @@ -6423,6 +6480,9 @@ impl From for ThreadItem { revised_prompt: image.revised_prompt, result: image.result, saved_path: image.saved_path, + started_at_ms: image.started_at_ms, + completed_at_ms: image.completed_at_ms, + duration_ms: image.duration_ms, }, CoreTurnItem::ContextCompaction(compaction) => { ThreadItem::ContextCompaction { id: compaction.id } @@ -10297,6 +10357,9 @@ mod tests { query: Some("docs".to_string()), queries: None, }, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }); assert_eq!( @@ -10308,6 +10371,9 @@ mod tests { query: Some("docs".to_string()), queries: None, }), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, } ); } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a54964f41154..4c0ce8e543e7 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -11,7 +11,6 @@ use crate::thread_state::TurnSummary; use crate::thread_state::resolve_server_request_on_thread_listener; use crate::thread_status::ThreadWatchActiveGuard; use crate::thread_status::ThreadWatchManager; -use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermissionProfile; use codex_app_server_protocol::AgentMessageDeltaNotification; @@ -166,7 +165,6 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, - analytics_events_client: Option, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -221,7 +219,6 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, - analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -863,6 +860,8 @@ pub(crate) async fn apply_bespoke_event_handling( status: DynamicToolCallStatus::InProgress, content_items: None, success: None, + started_at_ms: request.started_at_ms, + completed_at_ms: None, duration_ms: None, }; let notification = ItemStartedNotification { @@ -916,6 +915,8 @@ pub(crate) async fn apply_bespoke_event_handling( .collect(), ), success: Some(response.success), + started_at_ms: response.started_at_ms, + completed_at_ms: response.completed_at_ms, duration_ms, }; let notification = ItemCompletedNotification { @@ -961,6 +962,9 @@ pub(crate) async fn apply_bespoke_event_handling( model: Some(begin_event.model), reasoning_effort: Some(begin_event.reasoning_effort), agents_states: HashMap::new(), + started_at_ms: begin_event.started_at_ms, + completed_at_ms: None, + duration_ms: None, }; let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), @@ -1000,6 +1004,9 @@ pub(crate) async fn apply_bespoke_event_handling( model: Some(end_event.model), reasoning_effort: Some(end_event.reasoning_effort), agents_states, + started_at_ms: end_event.started_at_ms, + completed_at_ms: end_event.completed_at_ms, + duration_ms: end_event.duration_ms, }; let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), @@ -1022,6 +1029,9 @@ pub(crate) async fn apply_bespoke_event_handling( model: None, reasoning_effort: None, agents_states: HashMap::new(), + started_at_ms: begin_event.started_at_ms, + completed_at_ms: None, + duration_ms: None, }; let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), @@ -1050,6 +1060,9 @@ pub(crate) async fn apply_bespoke_event_handling( model: None, reasoning_effort: None, agents_states: [(receiver_id, received_status)].into_iter().collect(), + started_at_ms: end_event.started_at_ms, + completed_at_ms: end_event.completed_at_ms, + duration_ms: end_event.duration_ms, }; let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), @@ -1076,6 +1089,9 @@ pub(crate) async fn apply_bespoke_event_handling( model: None, reasoning_effort: None, agents_states: HashMap::new(), + started_at_ms: begin_event.started_at_ms, + completed_at_ms: None, + duration_ms: None, }; let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), @@ -1114,6 +1130,9 @@ pub(crate) async fn apply_bespoke_event_handling( model: None, reasoning_effort: None, agents_states, + started_at_ms: end_event.started_at_ms, + completed_at_ms: end_event.completed_at_ms, + duration_ms: end_event.duration_ms, }; let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), @@ -1135,6 +1154,9 @@ pub(crate) async fn apply_bespoke_event_handling( model: None, reasoning_effort: None, agents_states: HashMap::new(), + started_at_ms: begin_event.started_at_ms, + completed_at_ms: None, + duration_ms: None, }; let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), @@ -1177,6 +1199,9 @@ pub(crate) async fn apply_bespoke_event_handling( model: None, reasoning_effort: None, agents_states, + started_at_ms: end_event.started_at_ms, + completed_at_ms: end_event.completed_at_ms, + duration_ms: end_event.duration_ms, }; let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), @@ -1566,6 +1591,8 @@ pub(crate) async fn apply_bespoke_event_handling( command_actions, aggregated_output: None, exit_code: None, + started_at_ms: exec_command_begin_event.started_at_ms, + completed_at_ms: None, duration_ms: None, }; let notification = ItemStartedNotification { @@ -1676,7 +1703,6 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, - analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1865,7 +1891,6 @@ async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, turn_completion_metadata: TurnCompletionMetadata, - analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1880,10 +1905,6 @@ async fn emit_turn_completed_with_status( duration_ms: turn_completion_metadata.duration_ms, }, }; - if let Some(analytics_events_client) = analytics_events_client { - analytics_events_client - .track_notification(ServerNotification::TurnCompleted(notification.clone())); - } outgoing .send_server_notification(ServerNotification::TurnCompleted(notification)) .await; @@ -1947,6 +1968,8 @@ async fn start_command_execution_item( command_actions, aggregated_output: None, exit_code: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, }, }; @@ -1991,6 +2014,8 @@ async fn complete_command_execution_item( command_actions, aggregated_output: None, exit_code: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, }; let notification = ItemCompletedNotification { @@ -2069,7 +2094,6 @@ async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, turn_complete_event: TurnCompleteEvent, - analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2090,7 +2114,6 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, - analytics_events_client, outgoing, ) .await; @@ -2100,7 +2123,6 @@ async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, turn_aborted_event: TurnAbortedEvent, - analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2116,7 +2138,6 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, - analytics_events_client, outgoing, ) .await; @@ -2532,6 +2553,9 @@ async fn on_file_change_request_approval_response( id: item_id.clone(), changes, status, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, event_turn_id.clone(), &outgoing, @@ -2689,6 +2713,9 @@ fn collab_resume_begin_item( model: None, reasoning_effort: None, agents_states: HashMap::new(), + started_at_ms: begin_event.started_at_ms, + completed_at_ms: None, + duration_ms: None, } } @@ -2715,6 +2742,9 @@ fn collab_resume_end_item(end_event: codex_protocol::protocol::CollabResumeEndEv model: None, reasoning_effort: None, agents_states, + started_at_ms: end_event.started_at_ms, + completed_at_ms: end_event.completed_at_ms, + duration_ms: end_event.duration_ms, } } @@ -2733,6 +2763,8 @@ async fn construct_mcp_tool_call_notification( mcp_app_resource_uri: begin_event.mcp_app_resource_uri, result: None, error: None, + started_at_ms: begin_event.started_at_ms, + completed_at_ms: None, duration_ms: None, }; ItemStartedNotification { @@ -2781,6 +2813,8 @@ async fn construct_mcp_tool_call_end_notification( mcp_app_resource_uri: end_event.mcp_app_resource_uri, result, error, + started_at_ms: end_event.started_at_ms, + completed_at_ms: end_event.completed_at_ms, duration_ms, }; ItemCompletedNotification { @@ -2805,7 +2839,6 @@ mod tests { use codex_app_server_protocol::GuardianApprovalReviewStatus; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::TurnPlanStepStatus; - use codex_login::AuthManager; use codex_login::CodexAuth; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; @@ -2943,7 +2976,6 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, - analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -2958,7 +2990,6 @@ mod tests { self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), - Some(self.analytics_events_client.clone()), self.outgoing.clone(), self.thread_state.clone(), self.thread_watch_manager.clone(), @@ -3153,6 +3184,8 @@ mod tests { command_actions: completion_item.command_actions.clone(), aggregated_output: None, exit_code: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, } ); @@ -3297,13 +3330,6 @@ mod tests { outgoing: outgoing.clone(), thread_state: thread_state.clone(), thread_watch_manager: thread_watch_manager.clone(), - analytics_events_client: AnalyticsEventsClient::new( - AuthManager::from_auth_for_testing( - CodexAuth::create_dummy_chatgpt_auth_for_testing(), - ), - "http://localhost".to_string(), - Some(false), - ), codex_home: codex_home.path().to_path_buf(), }; @@ -3828,6 +3854,7 @@ mod tests { receiver_thread_id: ThreadId::new(), receiver_agent_nickname: None, receiver_agent_role: None, + started_at_ms: None, }; let item = collab_resume_begin_item(event.clone()); @@ -3841,6 +3868,9 @@ mod tests { model: None, reasoning_effort: None, agents_states: HashMap::new(), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }; assert_eq!(item, expected); } @@ -3854,6 +3884,9 @@ mod tests { receiver_agent_nickname: None, receiver_agent_role: None, status: codex_protocol::protocol::AgentStatus::NotFound, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }; let item = collab_resume_end_item(event.clone()); @@ -3873,6 +3906,9 @@ mod tests { )] .into_iter() .collect(), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }; assert_eq!(item, expected); } @@ -3941,7 +3977,6 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3993,7 +4028,6 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4044,7 +4078,6 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4255,6 +4288,7 @@ mod tests { arguments: Some(serde_json::json!({"server": ""})), }, mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), + started_at_ms: None, }; let thread_id = ThreadId::new().to_string(); @@ -4278,6 +4312,8 @@ mod tests { mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), result: None, error: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, }, }; @@ -4319,7 +4355,6 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4341,7 +4376,6 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4353,7 +4387,6 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4420,6 +4453,7 @@ mod tests { arguments: None, }, mcp_app_resource_uri: None, + started_at_ms: None, }; let thread_id = ThreadId::new().to_string(); @@ -4443,6 +4477,8 @@ mod tests { mcp_app_resource_uri: None, result: None, error: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, }, }; @@ -4475,6 +4511,8 @@ mod tests { mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), duration: Duration::from_nanos(92708), result: Ok(result), + started_at_ms: None, + completed_at_ms: None, }; let thread_id = ThreadId::new().to_string(); @@ -4504,6 +4542,8 @@ mod tests { })), })), error: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(0), }, }; @@ -4523,6 +4563,8 @@ mod tests { mcp_app_resource_uri: None, duration: Duration::from_millis(1), result: Err("boom".to_string()), + started_at_ms: None, + completed_at_ms: None, }; let thread_id = ThreadId::new().to_string(); @@ -4548,6 +4590,8 @@ mod tests { error: Some(McpToolCallError { message: "boom".to_string(), }), + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(1), }, }; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 89d07ca98790..7a284553f3f8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -7657,7 +7657,6 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), - Some(listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 34441f83a082..e7455961974b 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -163,6 +163,9 @@ impl ThreadScopedOutgoingMessageSender { } pub(crate) async fn send_server_notification(&self, notification: ServerNotification) { + self.outgoing + .analytics_events_client + .track_notification(notification.clone()); if self.connection_ids.is_empty() { return; } @@ -546,7 +549,7 @@ impl OutgoingMessageSender { targeted_connections = connection_ids.len(), "app-server event: {notification}" ); - let outgoing_message = OutgoingMessage::AppServerNotification(notification); + let outgoing_message = OutgoingMessage::AppServerNotification(notification.clone()); if connection_ids.is_empty() { if let Err(err) = self .sender @@ -580,7 +583,7 @@ impl OutgoingMessageSender { notification: ServerNotification, ) { tracing::trace!("app-server event: {notification}"); - let outgoing_message = OutgoingMessage::AppServerNotification(notification); + let outgoing_message = OutgoingMessage::AppServerNotification(notification.clone()); let (write_complete_tx, write_complete_rx) = oneshot::channel(); if let Err(err) = self .sender diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index 7ee21a2068f1..7b8344e822bd 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -333,6 +333,8 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res status, content_items, success, + started_at_ms, + completed_at_ms, duration_ms, } = started.item else { @@ -345,6 +347,8 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res assert_eq!(status, DynamicToolCallStatus::InProgress); assert_eq!(content_items, None); assert_eq!(success, None); + assert!(started_at_ms.is_some()); + assert_eq!(completed_at_ms, None); assert_eq!(duration_ms, None); // Read the tool call request from the app server. @@ -389,6 +393,8 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res status, content_items, success, + started_at_ms, + completed_at_ms, duration_ms, } = completed.item else { @@ -406,6 +412,8 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res }]) ); assert_eq!(success, Some(true)); + assert!(started_at_ms.is_some()); + assert!(completed_at_ms.is_some()); assert!(duration_ms.is_some()); timeout( diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 03f3db95f143..954ca5477e44 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -286,6 +286,8 @@ url = "{mcp_server_url}/mcp" mcp_app_resource_uri: None, result: Some(result), error: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, })?; assert!(serialized_item.len() < DEFAULT_OUTPUT_BYTES_CAP * 2 + 2048); 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 d9f5f039de78..7d2b295d33b3 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2193,16 +2193,30 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< }) .await??; let expected_readme_path = workspace.join("README.md"); - let expected_file_change = ThreadItem::FileChange { - id: "patch-call".to_string(), - changes: vec![codex_app_server_protocol::FileUpdateChange { + let ThreadItem::FileChange { + id, + changes, + status, + started_at_ms, + completed_at_ms, + duration_ms, + } = original_started + else { + unreachable!("loop ensures we break on file change items"); + }; + assert_eq!(id, "patch-call"); + assert_eq!( + changes, + vec![codex_app_server_protocol::FileUpdateChange { path: expected_readme_path.to_string_lossy().into_owned(), kind: PatchChangeKind::Add, diff: "new line\n".to_string(), - }], - status: PatchApplyStatus::InProgress, - }; - assert_eq!(original_started, expected_file_change); + }] + ); + assert_eq!(status, PatchApplyStatus::InProgress); + assert!(started_at_ms.is_some()); + assert_eq!(completed_at_ms, None); + assert_eq!(duration_ms, None); let original_request = timeout( DEFAULT_READ_TIMEOUT, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index aa507f39e4e9..e393937ce567 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2159,6 +2159,7 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { ref id, status, ref changes, + .. } = started_file_change else { unreachable!("loop ensures we break on file change items"); @@ -2616,20 +2617,35 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() } }) .await??; - assert_eq!( - spawn_started, - ThreadItem::CollabAgentToolCall { - id: SPAWN_CALL_ID.to_string(), - tool: CollabAgentTool::SpawnAgent, - status: CollabAgentToolCallStatus::InProgress, - sender_thread_id: thread.id.clone(), - receiver_thread_ids: Vec::new(), - prompt: Some(CHILD_PROMPT.to_string()), - model: Some(REQUESTED_MODEL.to_string()), - reasoning_effort: Some(REQUESTED_REASONING_EFFORT), - agents_states: HashMap::new(), - } - ); + let ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + started_at_ms, + completed_at_ms, + duration_ms, + } = spawn_started + else { + panic!("expected collab agent tool call item"); + }; + assert_eq!(id, SPAWN_CALL_ID); + assert_eq!(tool, CollabAgentTool::SpawnAgent); + assert_eq!(status, CollabAgentToolCallStatus::InProgress); + assert_eq!(sender_thread_id, thread.id); + assert_eq!(receiver_thread_ids, Vec::::new()); + assert_eq!(prompt.as_deref(), Some(CHILD_PROMPT)); + assert_eq!(model.as_deref(), Some(REQUESTED_MODEL)); + assert_eq!(reasoning_effort, Some(REQUESTED_REASONING_EFFORT)); + assert_eq!(agents_states, HashMap::new()); + assert!(started_at_ms.is_some()); + assert_eq!(completed_at_ms, None); + assert_eq!(duration_ms, None); let spawn_completed = timeout(DEFAULT_READ_TIMEOUT, async { loop { @@ -2656,6 +2672,9 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() model, reasoning_effort, agents_states, + started_at_ms, + completed_at_ms, + duration_ms, } = spawn_completed else { unreachable!("loop ensures we break on collab agent tool call items"); @@ -2667,6 +2686,9 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() assert_eq!(id, SPAWN_CALL_ID); assert_eq!(tool, CollabAgentTool::SpawnAgent); assert_eq!(status, CollabAgentToolCallStatus::Completed); + assert!(started_at_ms.is_some()); + assert!(completed_at_ms.is_some()); + assert!(duration_ms.is_some()); assert_eq!(sender_thread_id, thread.id); assert_eq!(receiver_thread_ids, vec![receiver_thread_id.clone()]); assert_eq!(prompt, Some(CHILD_PROMPT.to_string())); @@ -2840,6 +2862,9 @@ config_file = "./custom-role.toml" model, reasoning_effort, agents_states, + started_at_ms, + completed_at_ms, + duration_ms, } = spawn_completed else { unreachable!("loop ensures we break on collab agent tool call items"); @@ -2851,6 +2876,9 @@ config_file = "./custom-role.toml" assert_eq!(id, SPAWN_CALL_ID); assert_eq!(tool, CollabAgentTool::SpawnAgent); assert_eq!(status, CollabAgentToolCallStatus::Completed); + assert!(started_at_ms.is_some()); + assert!(completed_at_ms.is_some()); + assert!(duration_ms.is_some()); assert_eq!(sender_thread_id, thread.id); assert_eq!(receiver_thread_ids, vec![receiver_thread_id.clone()]); assert_eq!(prompt, Some(CHILD_PROMPT.to_string())); @@ -3158,6 +3186,7 @@ async fn turn_start_file_change_approval_decline_v2() -> Result<()> { ref id, status, ref changes, + .. } = started_file_change else { unreachable!("loop ensures we break on file change items"); diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index e7c79e6dd2a6..0af0aec0ea0c 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -188,6 +188,9 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { id: id.clone().unwrap_or_default(), query, action, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, })) } ResponseItem::ImageGenerationCall { @@ -202,6 +205,9 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { revised_prompt: revised_prompt.clone(), result: result.clone(), saved_path: None, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, )), _ => None, diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 85e7034405a4..cac92ad60a7d 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -413,6 +413,9 @@ fn parses_web_search_call() { query: Some("weather".to_string()), queries: None, }, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, } ), other => panic!("expected TurnItem::WebSearch, got {other:?}"), @@ -440,6 +443,9 @@ fn parses_web_search_open_page_call() { action: WebSearchAction::OpenPage { url: Some("https://example.com".to_string()), }, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, } ), other => panic!("expected TurnItem::WebSearch, got {other:?}"), @@ -469,6 +475,9 @@ fn parses_web_search_find_in_page_call() { url: Some("https://example.com".to_string()), pattern: Some("needle".to_string()), }, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, } ), other => panic!("expected TurnItem::WebSearch, got {other:?}"), @@ -491,6 +500,9 @@ fn parses_partial_web_search_call_without_action_as_other() { id: "ws_partial".to_string(), query: String::new(), action: WebSearchAction::Other, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, } ), other => panic!("expected TurnItem::WebSearch, got {other:?}"), diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index d2e990f44799..d45ec167a802 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -33,6 +33,7 @@ use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::hook_names::HookToolName; use crate::tools::sandboxing::PermissionRequestPayload; +use crate::turn_timing::now_unix_timestamp_ms; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::build_track_events_context; @@ -149,6 +150,7 @@ pub(crate) async fn handle_mcp_tool_call( .await }; + let started_at_ms = now_unix_timestamp_ms(); if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled { let result = notify_mcp_tool_call_skip( sess.as_ref(), @@ -158,6 +160,7 @@ pub(crate) async fn handle_mcp_tool_call( mcp_app_resource_uri.clone(), "MCP tool call blocked by app configuration".to_string(), /*already_started*/ false, + started_at_ms, ) .await; let status = if result.is_ok() { "ok" } else { "error" }; @@ -189,6 +192,7 @@ pub(crate) async fn handle_mcp_tool_call( call_id: call_id.clone(), invocation: invocation.clone(), mcp_app_resource_uri: mcp_app_resource_uri.clone(), + started_at_ms: Some(started_at_ms), }); notify_mcp_tool_call_event(sess.as_ref(), turn_context.as_ref(), tool_call_begin_event).await; @@ -215,6 +219,7 @@ pub(crate) async fn handle_mcp_tool_call( metadata.as_ref(), request_meta, mcp_app_resource_uri, + started_at_ms, ) .await; } @@ -228,6 +233,7 @@ pub(crate) async fn handle_mcp_tool_call( mcp_app_resource_uri.clone(), message, /*already_started*/ true, + started_at_ms, ) .await } @@ -241,6 +247,7 @@ pub(crate) async fn handle_mcp_tool_call( mcp_app_resource_uri.clone(), message, /*already_started*/ true, + started_at_ms, ) .await } @@ -253,6 +260,7 @@ pub(crate) async fn handle_mcp_tool_call( mcp_app_resource_uri.clone(), message, /*already_started*/ true, + started_at_ms, ) .await } @@ -283,6 +291,7 @@ pub(crate) async fn handle_mcp_tool_call( metadata.as_ref(), request_meta, mcp_app_resource_uri, + started_at_ms, ) .await } @@ -292,6 +301,7 @@ pub(crate) struct HandledMcpToolCall { pub(crate) tool_input: JsonValue, } +#[allow(clippy::too_many_arguments)] async fn handle_approved_mcp_tool_call( sess: &Session, turn_context: &TurnContext, @@ -300,6 +310,7 @@ async fn handle_approved_mcp_tool_call( metadata: Option<&McpToolApprovalMetadata>, request_meta: Option, mcp_app_resource_uri: Option, + started_at_ms: i64, ) -> HandledMcpToolCall { maybe_mark_thread_memory_mode_polluted(sess, turn_context).await; @@ -361,10 +372,13 @@ async fn handle_approved_mcp_tool_call( tracing::warn!("MCP tool call error: {error:?}"); } let duration = start.elapsed(); + let completed_at_ms = now_unix_timestamp_ms(); let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: call_id.to_string(), invocation, mcp_app_resource_uri, + started_at_ms: Some(started_at_ms), + completed_at_ms: Some(completed_at_ms), duration, result: truncate_mcp_tool_result_for_event(&result), }); @@ -1964,6 +1978,7 @@ fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool { .unwrap_or(true) } +#[allow(clippy::too_many_arguments)] async fn notify_mcp_tool_call_skip( sess: &Session, turn_context: &TurnContext, @@ -1972,12 +1987,14 @@ async fn notify_mcp_tool_call_skip( mcp_app_resource_uri: Option, message: String, already_started: bool, + started_at_ms: i64, ) -> Result { if !already_started { let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id: call_id.to_string(), invocation: invocation.clone(), mcp_app_resource_uri: mcp_app_resource_uri.clone(), + started_at_ms: Some(started_at_ms), }); notify_mcp_tool_call_event(sess, turn_context, tool_call_begin_event).await; } @@ -1988,6 +2005,8 @@ async fn notify_mcp_tool_call_skip( mcp_app_resource_uri, duration: Duration::ZERO, result: truncate_mcp_tool_result_for_event(&Err(message.clone())), + started_at_ms: Some(started_at_ms), + completed_at_ms: Some(now_unix_timestamp_ms()), }); notify_mcp_tool_call_event(sess, turn_context, tool_call_end_event).await; Err(message) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index e45db2084809..e88a3d5a194d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -298,6 +298,7 @@ use crate::tools::network_approval::build_network_policy_decider; use crate::tools::parallel::ToolCallRuntime; use crate::tools::sandboxing::ApprovalStore; use crate::turn_timing::TurnTimingState; +use crate::turn_timing::now_unix_timestamp_ms; use crate::turn_timing::record_turn_ttfm_metric; use crate::unified_exec::UnifiedExecProcessManager; use crate::windows_sandbox::WindowsSandboxLevelExt; @@ -1636,12 +1637,19 @@ impl Session { } pub(crate) async fn emit_turn_item_started(&self, turn_context: &TurnContext, item: &TurnItem) { + let started_at_ms = now_unix_timestamp_ms(); + self.turn_item_started_at_ms + .lock() + .await + .insert(item.id(), started_at_ms); + let mut item = item.clone(); + item.set_started_at_ms(started_at_ms); self.send_event( turn_context, EventMsg::ItemStarted(ItemStartedEvent { thread_id: self.conversation_id, turn_id: turn_context.sub_id.clone(), - item: item.clone(), + item, }), ) .await; @@ -1650,9 +1658,12 @@ impl Session { pub(crate) async fn emit_turn_item_completed( &self, turn_context: &TurnContext, - item: TurnItem, + mut item: TurnItem, ) { record_turn_ttfm_metric(turn_context, &item).await; + let started_at_ms = self.turn_item_started_at_ms.lock().await.remove(&item.id()); + let completed_at_ms = now_unix_timestamp_ms(); + item.set_completed_timing_ms(started_at_ms, completed_at_ms); self.send_event( turn_context, EventMsg::ItemCompleted(ItemCompletedEvent { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 6c6cc22a0eff..e1f947ffb397 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -22,6 +22,8 @@ pub(crate) struct Session { pub(super) pending_mcp_server_refresh_config: Mutex>, pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, + /// Native start timestamps for turn items that have started but not completed yet. + pub(super) turn_item_started_at_ms: Mutex>, pub(super) mailbox: Mailbox, pub(super) mailbox_rx: Mutex, pub(super) idle_pending_input: Mutex>, // TODO (jif) merge with mailbox! @@ -858,6 +860,7 @@ impl Session { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + turn_item_started_at_ms: Mutex::new(HashMap::new()), mailbox, mailbox_rx: Mutex::new(mailbox_rx), idle_pending_input: Mutex::new(Vec::new()), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 1afb28f35d19..febdc84cd4e4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3604,6 +3604,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + turn_item_started_at_ms: Mutex::new(HashMap::new()), mailbox, mailbox_rx: Mutex::new(mailbox_rx), idle_pending_input: Mutex::new(Vec::new()), @@ -5033,6 +5034,7 @@ where pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + turn_item_started_at_ms: Mutex::new(HashMap::new()), mailbox, mailbox_rx: Mutex::new(mailbox_rx), idle_pending_input: Mutex::new(Vec::new()), diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 23cd076404df..78ec2facfef6 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -21,6 +21,7 @@ use crate::session::turn_context::TurnContext; use crate::state::TaskKind; use crate::tools::format_exec_output_str; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; +use crate::turn_timing::now_unix_timestamp_ms; use crate::user_shell_command::user_shell_command_record_item; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::exec_output::StreamOutput; @@ -157,6 +158,7 @@ pub(crate) async fn execute_user_shell_command( let cwd = turn_context.cwd.clone(); let parsed_cmd = parse_command(&display_command); + let started_at_ms = now_unix_timestamp_ms(); session .send_event( turn_context.as_ref(), @@ -169,6 +171,7 @@ pub(crate) async fn execute_user_shell_command( parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, + started_at_ms: Some(started_at_ms), }), ) .await; @@ -212,6 +215,7 @@ pub(crate) async fn execute_user_shell_command( match exec_result { Err(CancelErr::Cancelled) => { + let completed_at_ms = now_unix_timestamp_ms(); let aborted_message = "command aborted by user".to_string(); let exec_output = ExecToolCallOutput { exit_code: -1, @@ -241,6 +245,8 @@ pub(crate) async fn execute_user_shell_command( parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, + started_at_ms: Some(started_at_ms), + completed_at_ms: Some(completed_at_ms), stdout: String::new(), stderr: aborted_message.clone(), aggregated_output: aborted_message.clone(), @@ -253,6 +259,7 @@ pub(crate) async fn execute_user_shell_command( .await; } Ok(Ok(output)) => { + let completed_at_ms = now_unix_timestamp_ms(); session .send_event( turn_context.as_ref(), @@ -265,6 +272,8 @@ pub(crate) async fn execute_user_shell_command( parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, + started_at_ms: Some(started_at_ms), + completed_at_ms: Some(completed_at_ms), stdout: output.stdout.text.clone(), stderr: output.stderr.text.clone(), aggregated_output: output.aggregated_output.text.clone(), @@ -297,6 +306,7 @@ pub(crate) async fn execute_user_shell_command( duration: Duration::ZERO, timed_out: false, }; + let completed_at_ms = now_unix_timestamp_ms(); session .send_event( turn_context.as_ref(), @@ -309,6 +319,8 @@ pub(crate) async fn execute_user_shell_command( parsed_cmd, source: ExecCommandSource::UserShell, interaction_input: None, + started_at_ms: Some(started_at_ms), + completed_at_ms: Some(completed_at_ms), stdout: exec_output.stdout.text.clone(), stderr: exec_output.stderr.text.clone(), aggregated_output: exec_output.aggregated_output.text.clone(), diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 2b215a043d2e..11e851a69f2a 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -3,6 +3,7 @@ use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::sandboxing::ToolError; +use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::exec_output::ExecToolCallOutput; @@ -82,6 +83,7 @@ pub(crate) async fn emit_exec_command_begin( parsed_cmd: parsed_cmd.to_vec(), source, interaction_input, + started_at_ms: Some(now_unix_timestamp_ms()), }), ) .await; @@ -190,6 +192,7 @@ impl ToolEmitter { turn_id: ctx.turn.sub_id.clone(), auto_approved: *auto_approved, changes: changes.clone(), + started_at_ms: Some(now_unix_timestamp_ms()), }), ) .await; @@ -206,6 +209,7 @@ impl ToolEmitter { } else { PatchApplyStatus::Failed }, + output.duration, ) .await; } @@ -224,6 +228,7 @@ impl ToolEmitter { } else { PatchApplyStatus::Failed }, + output.duration, ) .await; } @@ -238,6 +243,7 @@ impl ToolEmitter { (*message).to_string(), /*success*/ false, PatchApplyStatus::Failed, + Duration::ZERO, ) .await; } @@ -252,6 +258,7 @@ impl ToolEmitter { (*message).to_string(), /*success*/ false, PatchApplyStatus::Declined, + Duration::ZERO, ) .await; } @@ -467,6 +474,9 @@ async fn emit_exec_end( exec_input: ExecCommandInput<'_>, exec_result: ExecCommandResult, ) { + let completed_at_ms = now_unix_timestamp_ms(); + let duration_ms = i64::try_from(exec_result.duration.as_millis()).unwrap_or(i64::MAX); + let started_at_ms = completed_at_ms.checked_sub(duration_ms); ctx.session .send_event( ctx.turn, @@ -479,6 +489,8 @@ async fn emit_exec_end( parsed_cmd: exec_input.parsed_cmd.to_vec(), source: exec_input.source, interaction_input: exec_input.interaction_input.map(str::to_owned), + started_at_ms, + completed_at_ms: Some(completed_at_ms), stdout: exec_result.stdout, stderr: exec_result.stderr, aggregated_output: exec_result.aggregated_output, @@ -498,7 +510,12 @@ async fn emit_patch_end( stderr: String, success: bool, status: PatchApplyStatus, + duration: Duration, ) { + let completed_at_ms = now_unix_timestamp_ms(); + let duration_ms = i64::try_from(duration.as_millis()).ok(); + let started_at_ms = + duration_ms.and_then(|duration_ms| completed_at_ms.checked_sub(duration_ms)); ctx.session .send_event( ctx.turn, @@ -510,6 +527,9 @@ async fn emit_patch_end( success, changes, status, + started_at_ms, + completed_at_ms: Some(completed_at_ms), + duration_ms, }), ) .await; diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index b7e07090dc78..f6e665a71f33 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -7,6 +7,7 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::dynamic_tools::DynamicToolCallRequest; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::models::FunctionCallOutputContentItem; @@ -102,16 +103,19 @@ async fn request_dynamic_tool( } let started_at = Instant::now(); + let started_at_ms = now_unix_timestamp_ms(); let event = EventMsg::DynamicToolCallRequest(DynamicToolCallRequest { call_id: call_id.clone(), turn_id: turn_id.clone(), namespace: namespace.clone(), tool: tool.clone(), arguments: arguments.clone(), + started_at_ms: Some(started_at_ms), }); session.send_event(turn_context, event).await; let response = rx_response.await.ok(); + let completed_at_ms = now_unix_timestamp_ms(); let response_event = match &response { Some(response) => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { call_id, @@ -122,6 +126,8 @@ async fn request_dynamic_tool( content_items: response.content_items.clone(), success: response.success, error: None, + started_at_ms: Some(started_at_ms), + completed_at_ms: Some(completed_at_ms), duration: started_at.elapsed(), }), None => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { @@ -133,6 +139,8 @@ async fn request_dynamic_tool( content_items: Vec::new(), success: false, error: Some("dynamic tool call was cancelled before receiving a response".to_string()), + started_at_ms: Some(started_at_ms), + completed_at_ms: Some(completed_at_ms), duration: started_at.elapsed(), }), }; diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs index fa4a066741e7..06db72af381a 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -25,6 +25,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::McpToolCallBeginEvent; @@ -261,8 +262,16 @@ async fn handle_list_resources( arguments: arguments.clone(), }; - emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; let start = Instant::now(); + let started_at_ms = now_unix_timestamp_ms(); + emit_tool_call_begin( + &session, + turn.as_ref(), + &call_id, + invocation.clone(), + started_at_ms, + ) + .await; let payload_result: Result = async { if let Some(server_name) = server.clone() { @@ -310,6 +319,7 @@ async fn handle_list_resources( turn.as_ref(), &call_id, invocation, + started_at_ms, duration, Ok(call_tool_result_from_content(&content, output.success)), ) @@ -324,6 +334,7 @@ async fn handle_list_resources( turn.as_ref(), &call_id, invocation, + started_at_ms, duration, Err(message.clone()), ) @@ -339,6 +350,7 @@ async fn handle_list_resources( turn.as_ref(), &call_id, invocation, + started_at_ms, duration, Err(message.clone()), ) @@ -369,8 +381,16 @@ async fn handle_list_resource_templates( arguments: arguments.clone(), }; - emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; let start = Instant::now(); + let started_at_ms = now_unix_timestamp_ms(); + emit_tool_call_begin( + &session, + turn.as_ref(), + &call_id, + invocation.clone(), + started_at_ms, + ) + .await; let payload_result: Result = async { if let Some(server_name) = server.clone() { @@ -420,6 +440,7 @@ async fn handle_list_resource_templates( turn.as_ref(), &call_id, invocation, + started_at_ms, duration, Ok(call_tool_result_from_content(&content, output.success)), ) @@ -434,6 +455,7 @@ async fn handle_list_resource_templates( turn.as_ref(), &call_id, invocation, + started_at_ms, duration, Err(message.clone()), ) @@ -449,6 +471,7 @@ async fn handle_list_resource_templates( turn.as_ref(), &call_id, invocation, + started_at_ms, duration, Err(message.clone()), ) @@ -475,8 +498,16 @@ async fn handle_read_resource( arguments: arguments.clone(), }; - emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; let start = Instant::now(); + let started_at_ms = now_unix_timestamp_ms(); + emit_tool_call_begin( + &session, + turn.as_ref(), + &call_id, + invocation.clone(), + started_at_ms, + ) + .await; let payload_result: Result = async { let result = session @@ -511,6 +542,7 @@ async fn handle_read_resource( turn.as_ref(), &call_id, invocation, + started_at_ms, duration, Ok(call_tool_result_from_content(&content, output.success)), ) @@ -525,6 +557,7 @@ async fn handle_read_resource( turn.as_ref(), &call_id, invocation, + started_at_ms, duration, Err(message.clone()), ) @@ -540,6 +573,7 @@ async fn handle_read_resource( turn.as_ref(), &call_id, invocation, + started_at_ms, duration, Err(message.clone()), ) @@ -563,6 +597,7 @@ async fn emit_tool_call_begin( turn: &TurnContext, call_id: &str, invocation: McpInvocation, + started_at_ms: i64, ) { session .send_event( @@ -571,6 +606,7 @@ async fn emit_tool_call_begin( call_id: call_id.to_string(), invocation, mcp_app_resource_uri: None, + started_at_ms: Some(started_at_ms), }), ) .await; @@ -581,9 +617,11 @@ async fn emit_tool_call_end( turn: &TurnContext, call_id: &str, invocation: McpInvocation, + started_at_ms: i64, duration: Duration, result: Result, ) { + let completed_at_ms = now_unix_timestamp_ms(); session .send_event( turn, @@ -591,6 +629,8 @@ async fn emit_tool_call_end( call_id: call_id.to_string(), invocation, mcp_app_resource_uri: None, + started_at_ms: Some(started_at_ms), + completed_at_ms: Some(completed_at_ms), duration, result, }), diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index 8c00b0a13cb7..5b07670176e3 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -29,6 +29,7 @@ impl ToolHandler for Handler { .agent_control .get_agent_metadata(agent_id) .unwrap_or_default(); + let timing = CollabToolExecutionTiming::start(); session .send_event( &turn, @@ -36,6 +37,7 @@ impl ToolHandler for Handler { call_id: call_id.clone(), sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, + started_at_ms: timing.started_at_ms(), } .into(), ) @@ -49,6 +51,7 @@ impl ToolHandler for Handler { Ok(mut status_rx) => status_rx.borrow_and_update().clone(), Err(err) => { let status = session.services.agent_control.get_status(agent_id).await; + let (started_at_ms, completed_at_ms, duration_ms) = timing.finish(); session .send_event( &turn, @@ -59,6 +62,9 @@ impl ToolHandler for Handler { receiver_agent_nickname: receiver_agent.agent_nickname.clone(), receiver_agent_role: receiver_agent.agent_role.clone(), status, + started_at_ms, + completed_at_ms, + duration_ms, } .into(), ) @@ -70,6 +76,7 @@ impl ToolHandler for Handler { .await .map_err(|err| collab_agent_error(agent_id, err)) .map(|_| ()); + let (started_at_ms, completed_at_ms, duration_ms) = timing.finish(); session .send_event( &turn, @@ -80,6 +87,9 @@ impl ToolHandler for Handler { receiver_agent_nickname: receiver_agent.agent_nickname, receiver_agent_role: receiver_agent.agent_role, status: status.clone(), + started_at_ms, + completed_at_ms, + duration_ms, } .into(), ) diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index 2d4f2c3f47e8..579e7a6aa0a6 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -41,6 +41,7 @@ impl ToolHandler for Handler { )); } + let timing = CollabToolExecutionTiming::start(); session .send_event( &turn, @@ -50,6 +51,7 @@ impl ToolHandler for Handler { receiver_thread_id, receiver_agent_nickname: receiver_agent.agent_nickname.clone(), receiver_agent_role: receiver_agent.agent_role.clone(), + started_at_ms: timing.started_at_ms(), } .into(), ) @@ -96,6 +98,7 @@ impl ToolHandler for Handler { } else { (receiver_agent, None) }; + let (started_at_ms, completed_at_ms, duration_ms) = timing.finish(); session .send_event( &turn, @@ -106,6 +109,9 @@ impl ToolHandler for Handler { receiver_agent_nickname: receiver_agent.agent_nickname, receiver_agent_role: receiver_agent.agent_role, status: status.clone(), + started_at_ms, + completed_at_ms, + duration_ms, } .into(), ) diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 4ae3240cb384..962557b65706 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -40,6 +40,7 @@ impl ToolHandler for Handler { .await .map_err(|err| collab_agent_error(receiver_thread_id, err))?; } + let timing = CollabToolExecutionTiming::start(); session .send_event( &turn, @@ -48,6 +49,7 @@ impl ToolHandler for Handler { sender_thread_id: session.conversation_id, receiver_thread_id, prompt: prompt.clone(), + started_at_ms: timing.started_at_ms(), } .into(), ) @@ -62,6 +64,7 @@ impl ToolHandler for Handler { .agent_control .get_status(receiver_thread_id) .await; + let (started_at_ms, completed_at_ms, duration_ms) = timing.finish(); session .send_event( &turn, @@ -73,6 +76,9 @@ impl ToolHandler for Handler { receiver_agent_role: receiver_agent.agent_role, prompt, status, + started_at_ms, + completed_at_ms, + duration_ms, } .into(), ) diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 777cb9be1c86..347ca8868edd 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -46,6 +46,7 @@ impl ToolHandler for Handler { "Agent depth limit reached. Solve the task yourself.".to_string(), )); } + let timing = CollabToolExecutionTiming::start(); session .send_event( &turn, @@ -55,6 +56,7 @@ impl ToolHandler for Handler { prompt: prompt.clone(), model: args.model.clone().unwrap_or_default(), reasoning_effort: args.reasoning_effort.unwrap_or_default(), + started_at_ms: timing.started_at_ms(), } .into(), ) @@ -149,6 +151,7 @@ impl ToolHandler for Handler { .and_then(|snapshot| snapshot.reasoning_effort) .unwrap_or(args.reasoning_effort.unwrap_or_default()); let nickname = new_agent_nickname.clone(); + let (started_at_ms, completed_at_ms, duration_ms) = timing.finish(); session .send_event( &turn, @@ -162,6 +165,9 @@ impl ToolHandler for Handler { model: effective_model, reasoning_effort: effective_reasoning_effort, status, + started_at_ms, + completed_at_ms, + duration_ms, } .into(), ) diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index 77fa5f83a240..be908fa4313b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -69,6 +69,7 @@ impl ToolHandler for Handler { ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS), }; + let timing = CollabToolExecutionTiming::start(); session .send_event( &turn, @@ -77,6 +78,7 @@ impl ToolHandler for Handler { receiver_thread_ids: receiver_thread_ids.clone(), receiver_agents: receiver_agents.clone(), call_id: call_id.clone(), + started_at_ms: timing.started_at_ms(), } .into(), ) @@ -99,6 +101,7 @@ impl ToolHandler for Handler { Err(err) => { let mut statuses = HashMap::with_capacity(1); statuses.insert(*id, session.services.agent_control.get_status(*id).await); + let (started_at_ms, completed_at_ms, duration_ms) = timing.finish(); session .send_event( &turn, @@ -110,6 +113,9 @@ impl ToolHandler for Handler { &receiver_agents, ), statuses, + started_at_ms, + completed_at_ms, + duration_ms, } .into(), ) @@ -167,6 +173,7 @@ impl ToolHandler for Handler { timed_out, }; + let (started_at_ms, completed_at_ms, duration_ms) = timing.finish(); session .send_event( &turn, @@ -175,6 +182,9 @@ impl ToolHandler for Handler { call_id, agent_statuses, statuses: statuses_by_id, + started_at_ms, + completed_at_ms, + duration_ms, } .into(), ) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index c01755cb2b2c..ede49ff5cb86 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -8,6 +8,7 @@ use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::turn_timing::now_unix_timestamp_ms; use codex_features::Feature; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::AgentPath; @@ -32,6 +33,32 @@ pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIME pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS; +#[derive(Clone, Copy)] +pub(crate) struct CollabToolExecutionTiming { + started_at_ms: i64, +} + +impl CollabToolExecutionTiming { + pub(crate) fn start() -> Self { + Self { + started_at_ms: now_unix_timestamp_ms(), + } + } + + pub(crate) fn started_at_ms(self) -> Option { + Some(self.started_at_ms) + } + + pub(crate) fn finish(self) -> (Option, Option, Option) { + let completed_at_ms = now_unix_timestamp_ms(); + ( + Some(self.started_at_ms), + Some(completed_at_ms), + completed_at_ms.checked_sub(self.started_at_ms), + ) + } +} + pub(crate) fn function_arguments(payload: ToolPayload) -> Result { match payload { ToolPayload::Function { arguments } => Ok(arguments), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index 8074f7fe04a7..89bfc3057958 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -38,6 +38,7 @@ impl ToolHandler for Handler { "root is not a spawned agent".to_string(), )); } + let timing = CollabToolExecutionTiming::start(); session .send_event( &turn, @@ -45,6 +46,7 @@ impl ToolHandler for Handler { call_id: call_id.clone(), sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, + started_at_ms: timing.started_at_ms(), } .into(), ) @@ -58,6 +60,7 @@ impl ToolHandler for Handler { Ok(mut status_rx) => status_rx.borrow_and_update().clone(), Err(err) => { let status = session.services.agent_control.get_status(agent_id).await; + let (started_at_ms, completed_at_ms, duration_ms) = timing.finish(); session .send_event( &turn, @@ -68,6 +71,9 @@ impl ToolHandler for Handler { receiver_agent_nickname: receiver_agent.agent_nickname.clone(), receiver_agent_role: receiver_agent.agent_role.clone(), status, + started_at_ms, + completed_at_ms, + duration_ms, } .into(), ) @@ -82,6 +88,7 @@ impl ToolHandler for Handler { .await .map_err(|err| collab_agent_error(agent_id, err)) .map(|_| ()); + let (started_at_ms, completed_at_ms, duration_ms) = timing.finish(); session .send_event( &turn, @@ -92,6 +99,9 @@ impl ToolHandler for Handler { receiver_agent_nickname: receiver_agent.agent_nickname, receiver_agent_role: receiver_agent.agent_role, status: status.clone(), + started_at_ms, + completed_at_ms, + duration_ms, } .into(), ) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index a42cde8f62fe..c9719b9c5169 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -92,6 +92,7 @@ async fn handle_message_submission( "Tasks can't be assigned to the root agent".to_string(), )); } + let timing = CollabToolExecutionTiming::start(); session .send_event( &turn, @@ -100,6 +101,7 @@ async fn handle_message_submission( sender_thread_id: session.conversation_id, receiver_thread_id, prompt: prompt.clone(), + started_at_ms: timing.started_at_ms(), } .into(), ) @@ -127,6 +129,7 @@ async fn handle_message_submission( .agent_control .get_status(receiver_thread_id) .await; + let (started_at_ms, completed_at_ms, duration_ms) = timing.finish(); session .send_event( &turn, @@ -138,6 +141,9 @@ async fn handle_message_submission( receiver_agent_role: receiver_agent.agent_role, prompt, status, + started_at_ms, + completed_at_ms, + duration_ms, } .into(), ) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 26b6750c46f5..3170a157b7e7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -45,6 +45,7 @@ impl ToolHandler for Handler { let session_source = turn.session_source.clone(); let child_depth = next_thread_spawn_depth(&session_source); + let timing = CollabToolExecutionTiming::start(); session .send_event( &turn, @@ -54,6 +55,7 @@ impl ToolHandler for Handler { prompt: prompt.clone(), model: args.model.clone().unwrap_or_default(), reasoning_effort: args.reasoning_effort.unwrap_or_default(), + started_at_ms: timing.started_at_ms(), } .into(), ) @@ -169,6 +171,7 @@ impl ToolHandler for Handler { .and_then(|snapshot| snapshot.reasoning_effort) .unwrap_or(args.reasoning_effort.unwrap_or_default()); let nickname = new_agent_nickname.clone(); + let (started_at_ms, completed_at_ms, duration_ms) = timing.finish(); session .send_event( &turn, @@ -182,6 +185,9 @@ impl ToolHandler for Handler { model: effective_model, reasoning_effort: effective_reasoning_effort, status, + started_at_ms, + completed_at_ms, + duration_ms, } .into(), ) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index 778c57be2136..dfcb6edf3bc6 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -44,6 +44,7 @@ impl ToolHandler for Handler { let mut mailbox_seq_rx = session.subscribe_mailbox_seq(); + let timing = CollabToolExecutionTiming::start(); session .send_event( &turn, @@ -52,6 +53,7 @@ impl ToolHandler for Handler { receiver_thread_ids: Vec::new(), receiver_agents: Vec::new(), call_id: call_id.clone(), + started_at_ms: timing.started_at_ms(), } .into(), ) @@ -65,6 +67,7 @@ impl ToolHandler for Handler { }; let result = WaitAgentResult::from_timed_out(timed_out); + let (started_at_ms, completed_at_ms, duration_ms) = timing.finish(); session .send_event( &turn, @@ -73,6 +76,9 @@ impl ToolHandler for Handler { call_id, agent_statuses: Vec::new(), statuses: HashMap::new(), + started_at_ms, + completed_at_ms, + duration_ms, } .into(), ) 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 053f03ca90c7..bf984bc264f4 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -303,6 +303,14 @@ 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), + .. + }) => Some(item.clone()), + _ => None, + }) + .await; let begin = wait_for_event_match(&codex, |ev| match ev { EventMsg::WebSearchBegin(event) => Some(event.clone()), _ => None, @@ -317,8 +325,21 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { }) .await; + assert_eq!(started.id, "web-search-1"); + assert!(started.started_at_ms.is_some()); + assert_eq!(started.completed_at_ms, None); + assert_eq!(started.duration_ms, None); assert_eq!(begin.call_id, "web-search-1"); assert_eq!(completed.id, begin.call_id); + assert_eq!(completed.started_at_ms, started.started_at_ms); + assert!(completed.completed_at_ms.is_some()); + assert_eq!( + completed.duration_ms, + completed + .started_at_ms + .zip(completed.completed_at_ms) + .and_then(|(started_at_ms, completed_at_ms)| completed_at_ms.checked_sub(started_at_ms)) + ); assert_eq!( completed.action, WebSearchAction::Search { diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 0947f4fba76e..98404e515ed1 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1101,6 +1101,8 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { let EventMsg::McpToolCallBegin(begin) = begin_event else { unreachable!("begin"); }; + let started_at_ms = begin.started_at_ms; + assert!(started_at_ms.is_some()); assert_eq!( begin, McpToolCallBeginEvent { @@ -1111,6 +1113,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { arguments: Some(json!({})), }, mcp_app_resource_uri: None, + started_at_ms, }, ); diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 1285b9f9251e..8fee9037795c 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -117,7 +117,7 @@ async fn user_shell_cmd_can_be_interrupted() { .unwrap(); // Wait until it has started (ExecCommandBegin), then interrupt. - let _begin = wait_for_event_match(codex, |ev| match ev { + let begin = wait_for_event_match(codex, |ev| match ev { EventMsg::ExecCommandBegin(event) if event.source == ExecCommandSource::UserShell => { Some(event.clone()) } @@ -126,6 +126,16 @@ async fn user_shell_cmd_can_be_interrupted() { .await; codex.submit(Op::Interrupt).await.unwrap(); + let end = wait_for_event_match(codex, |ev| match ev { + EventMsg::ExecCommandEnd(event) if event.source == ExecCommandSource::UserShell => { + Some(event.clone()) + } + _ => None, + }) + .await; + assert_eq!(end.started_at_ms, begin.started_at_ms); + assert!(end.completed_at_ms.is_some()); + // Expect a TurnAborted(Interrupted) notification. let msg = wait_for_event_with_timeout( codex, diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 1641398ae69f..7198e9a07a3b 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -296,6 +296,7 @@ impl EventProcessorWithJsonOutput { id: raw_id, query, action, + .. } => Some(ExecThreadItem { id: make_id(), details: ThreadItemDetails::WebSearch(WebSearchItem { 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..d5569c030c91 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -173,6 +173,8 @@ fn command_execution_started_and_completed_translate_to_thread_events() { command_actions: Vec::::new(), aggregated_output: None, exit_code: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, }; @@ -212,6 +214,8 @@ fn command_execution_started_and_completed_translate_to_thread_events() { command_actions: Vec::::new(), aggregated_output: Some("a.txt\n".to_string()), exit_code: Some(0), + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(3), }, thread_id: "thread-1".to_string(), @@ -359,6 +363,9 @@ fn web_search_completion_preserves_query_and_action() { query: Some("rust async await".to_string()), queries: None, }), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), @@ -396,6 +403,9 @@ fn web_search_start_and_completion_reuse_item_id() { id: "search-1".to_string(), query: String::new(), action: None, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), @@ -410,6 +420,9 @@ fn web_search_start_and_completion_reuse_item_id() { query: Some("rust async await".to_string()), queries: None, }), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), @@ -468,6 +481,8 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { mcp_app_resource_uri: None, result: None, error: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, }, thread_id: "thread-1".to_string(), @@ -488,6 +503,8 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { meta: None, })), error: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(1_000), }, thread_id: "thread-1".to_string(), @@ -555,6 +572,8 @@ fn mcp_tool_call_failure_sets_failed_status() { error: Some(McpToolCallError { message: "tool exploded".to_string(), }), + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(5), }, thread_id: "thread-1".to_string(), @@ -600,6 +619,8 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { mcp_app_resource_uri: None, result: None, error: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, }, thread_id: "thread-1".to_string(), @@ -623,6 +644,8 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { meta: None, })), error: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(10), }, thread_id: "thread-1".to_string(), @@ -692,6 +715,9 @@ fn collab_spawn_begin_and_end_emit_item_events() { model: Some("gpt-5".to_string()), reasoning_effort: None, agents_states: std::collections::HashMap::new(), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, thread_id: "thread-parent".to_string(), turn_id: "turn-1".to_string(), @@ -714,6 +740,9 @@ fn collab_spawn_begin_and_end_emit_item_events() { message: None, }, )]), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, thread_id: "thread-parent".to_string(), turn_id: "turn-1".to_string(), @@ -792,6 +821,9 @@ fn file_change_completion_maps_change_kinds() { }, ], status: ApiPatchApplyStatus::Completed, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), @@ -842,6 +874,9 @@ fn file_change_declined_maps_to_failed_status() { diff: "@@ -1 +1 @@".to_string(), }], status: ApiPatchApplyStatus::Declined, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), @@ -1292,6 +1327,8 @@ fn turn_completion_reconciles_started_items_from_turn_items() { command_actions: Vec::::new(), aggregated_output: None, exit_code: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, }, thread_id: "thread-1".to_string(), @@ -1330,6 +1367,8 @@ fn turn_completion_reconciles_started_items_from_turn_items() { command_actions: Vec::::new(), aggregated_output: Some("a.txt\n".to_string()), exit_code: Some(0), + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(3), }], status: TurnStatus::Completed, diff --git a/codex-rs/protocol/src/dynamic_tools.rs b/codex-rs/protocol/src/dynamic_tools.rs index 2bee24972b2a..e756c14ce0cc 100644 --- a/codex-rs/protocol/src/dynamic_tools.rs +++ b/codex-rs/protocol/src/dynamic_tools.rs @@ -26,6 +26,9 @@ pub struct DynamicToolCallRequest { pub namespace: Option, pub tool: String, pub arguments: JsonValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 687958857990..df3ad3ebedd3 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -112,6 +112,15 @@ pub struct WebSearchItem { pub id: String, pub query: String, pub action: WebSearchAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] @@ -125,6 +134,15 @@ pub struct ImageGenerationItem { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub saved_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] @@ -365,6 +383,9 @@ impl WebSearchItem { call_id: self.id.clone(), query: self.query.clone(), action: self.action.clone(), + started_at_ms: self.started_at_ms, + completed_at_ms: self.completed_at_ms, + duration_ms: self.duration_ms, }) } } @@ -377,11 +398,40 @@ impl ImageGenerationItem { revised_prompt: self.revised_prompt.clone(), result: self.result.clone(), saved_path: self.saved_path.clone(), + started_at_ms: self.started_at_ms, + completed_at_ms: self.completed_at_ms, + duration_ms: self.duration_ms, }) } } impl TurnItem { + pub fn set_started_at_ms(&mut self, started_at_ms: i64) { + match self { + TurnItem::WebSearch(item) => item.started_at_ms = Some(started_at_ms), + TurnItem::ImageGeneration(item) => item.started_at_ms = Some(started_at_ms), + _ => {} + } + } + + pub fn set_completed_timing_ms(&mut self, started_at_ms: Option, completed_at_ms: i64) { + match self { + TurnItem::WebSearch(item) => { + item.started_at_ms = started_at_ms; + item.completed_at_ms = Some(completed_at_ms); + item.duration_ms = started_at_ms + .and_then(|started_at_ms| completed_at_ms.checked_sub(started_at_ms)); + } + TurnItem::ImageGeneration(item) => { + item.started_at_ms = started_at_ms; + item.completed_at_ms = Some(completed_at_ms); + item.duration_ms = started_at_ms + .and_then(|started_at_ms| completed_at_ms.checked_sub(started_at_ms)); + } + _ => {} + } + } + pub fn id(&self) -> String { match self { TurnItem::UserMessage(item) => item.id.clone(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1eae3213fe95..0d8bb9068267 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1854,10 +1854,12 @@ impl HasLegacyEvent for ItemStartedEvent { match &self.item { TurnItem::WebSearch(item) => vec![EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id: item.id.clone(), + started_at_ms: item.started_at_ms, })], TurnItem::ImageGeneration(item) => { vec![EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { call_id: item.id.clone(), + started_at_ms: item.started_at_ms, })] } _ => Vec::new(), @@ -2359,6 +2361,9 @@ pub struct McpToolCallBeginEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub mcp_app_resource_uri: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq)] @@ -2369,6 +2374,12 @@ pub struct McpToolCallEndEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub mcp_app_resource_uri: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, #[ts(type = "string")] pub duration: Duration, /// Result of the tool call. Note this could be an error. @@ -2394,6 +2405,14 @@ pub struct DynamicToolCallResponseEvent { pub success: bool, /// Optional error text when the tool call failed before producing a response. pub error: Option, + /// Unix timestamp (in milliseconds) when dynamic tool execution started, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, + /// Unix timestamp (in milliseconds) when dynamic tool execution completed, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, /// The duration of the dynamic tool call. #[ts(type = "string")] pub duration: Duration, @@ -2411,6 +2430,9 @@ impl McpToolCallEndEvent { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct WebSearchBeginEvent { pub call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] @@ -2418,11 +2440,23 @@ pub struct WebSearchEndEvent { pub call_id: String, pub query: String, pub action: WebSearchAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ImageGenerationBeginEvent { pub call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] @@ -2436,6 +2470,15 @@ pub struct ImageGenerationEndEvent { #[serde(skip_serializing_if = "Option::is_none")] #[ts(optional)] pub saved_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub duration_ms: Option, } // Conversation kept for backward compatibility. @@ -3103,6 +3146,9 @@ pub struct ExecCommandBeginEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub interaction_input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] @@ -3127,6 +3173,12 @@ pub struct ExecCommandEndEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub interaction_input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, /// Captured stdout pub stdout: String, @@ -3248,6 +3300,9 @@ pub struct PatchApplyBeginEvent { pub auto_approved: bool, /// The changes to be applied. pub changes: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] @@ -3277,6 +3332,15 @@ pub struct PatchApplyEndEvent { pub changes: HashMap, /// Completion status for this patch application. pub status: PatchApplyStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] @@ -3808,6 +3872,9 @@ pub struct CollabAgentSpawnBeginEvent { pub prompt: String, pub model: String, pub reasoning_effort: ReasoningEffortConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] @@ -3859,6 +3926,15 @@ pub struct CollabAgentSpawnEndEvent { pub reasoning_effort: ReasoningEffortConfig, /// Last known status of the new agent reported to the sender agent. pub status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -3872,6 +3948,9 @@ pub struct CollabAgentInteractionBeginEvent { /// Prompt sent from the sender to the receiver. Can be empty to prevent CoT /// leaking at the beginning. pub prompt: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -3893,6 +3972,15 @@ pub struct CollabAgentInteractionEndEvent { pub prompt: String, /// Last known status of the receiver agent reported to the sender agent. pub status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -3906,6 +3994,9 @@ pub struct CollabWaitingBeginEvent { pub receiver_agents: Vec, /// ID of the waiting call. pub call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -3919,6 +4010,15 @@ pub struct CollabWaitingEndEvent { pub agent_statuses: Vec, /// Last known status of the receiver agents reported to the sender agent. pub statuses: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -3929,6 +4029,9 @@ pub struct CollabCloseBeginEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receiver. pub receiver_thread_id: ThreadId, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -3948,6 +4051,15 @@ pub struct CollabCloseEndEvent { /// Last known status of the receiver agent reported to the sender agent before /// the close. pub status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -3964,6 +4076,9 @@ pub struct CollabResumeBeginEvent { /// Optional role assigned to the receiver agent. #[serde(default, skip_serializing_if = "Option::is_none")] pub receiver_agent_role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -3983,6 +4098,15 @@ pub struct CollabResumeEndEvent { /// Last known status of the receiver agent reported to the sender agent after /// resume. pub status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub started_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub completed_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub duration_ms: Option, } #[cfg(test)] @@ -4642,6 +4766,9 @@ mod tests { query: Some("find docs".into()), queries: None, }, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }), }; @@ -4679,6 +4806,9 @@ mod tests { revised_prompt: None, result: String::new(), saved_path: None, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }), }; @@ -4701,6 +4831,9 @@ mod tests { revised_prompt: Some("A tiny blue square".into()), result: "Zm9v".into(), saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }), }; diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 22615623f3c9..1b4591870bae 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -203,6 +203,9 @@ mod tests { revised_prompt: Some("final prompt".into()), result: "Zm9v".into(), saved_path: None, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }); assert!(should_persist_event_msg( diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 425975823538..e2a1e6f16c52 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -889,10 +889,20 @@ fn thread_item_to_core(item: &ThreadItem) -> Option { summary_text: summary.clone(), raw_content: content.clone(), })), - ThreadItem::WebSearch { id, query, action } => Some(TurnItem::WebSearch(WebSearchItem { + ThreadItem::WebSearch { + id, + query, + action, + started_at_ms, + completed_at_ms, + duration_ms, + } => Some(TurnItem::WebSearch(WebSearchItem { id: id.clone(), query: query.clone(), action: app_server_web_search_action_to_core(action.clone()?)?, + started_at_ms: *started_at_ms, + completed_at_ms: *completed_at_ms, + duration_ms: *duration_ms, })), ThreadItem::ImageGeneration { id, @@ -900,12 +910,18 @@ fn thread_item_to_core(item: &ThreadItem) -> Option { revised_prompt, result, saved_path, + started_at_ms, + completed_at_ms, + duration_ms, } => Some(TurnItem::ImageGeneration(ImageGenerationItem { id: id.clone(), status: status.clone(), revised_prompt: revised_prompt.clone(), result: result.clone(), saved_path: saved_path.clone(), + started_at_ms: *started_at_ms, + completed_at_ms: *completed_at_ms, + duration_ms: *duration_ms, })), ThreadItem::ContextCompaction { id } => { Some(TurnItem::ContextCompaction(ContextCompactionItem { @@ -936,6 +952,7 @@ fn command_execution_started_event(turn_id: &str, item: &ThreadItem) -> Option Option Option command_actions, aggregated_output, exit_code, + started_at_ms, + completed_at_ms, duration_ms, } = item else { @@ -1017,6 +1037,8 @@ fn command_execution_completed_event(turn_id: &str, item: &ThreadItem) -> Option .collect(), source: source.to_core(), interaction_input: None, + started_at_ms: *started_at_ms, + completed_at_ms: *completed_at_ms, stdout: String::new(), stderr: String::new(), aggregated_output: aggregated_output.clone(), @@ -1210,6 +1232,8 @@ mod tests { }], aggregated_output: None, exit_code: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, }; @@ -1266,6 +1290,8 @@ mod tests { }], aggregated_output: Some("hello world\n".to_string()), exit_code: Some(0), + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(5), }; let (_, completed_events) = server_notification_thread_events( @@ -1301,6 +1327,8 @@ mod tests { command_actions: vec![], aggregated_output: None, exit_code: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, }; @@ -1351,6 +1379,8 @@ mod tests { }], aggregated_output: Some("hello world\n".to_string()), exit_code: Some(0), + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(5), }], status: TurnStatus::Completed, @@ -1606,6 +1636,9 @@ mod tests { id: "search-1".to_string(), query: "ratatui stylize".to_string(), action: Some(codex_app_server_protocol::WebSearchAction::Other), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, ThreadItem::ImageGeneration { id: "image-1".to_string(), @@ -1613,6 +1646,9 @@ mod tests { revised_prompt: Some("diagram".to_string()), result: "image.png".to_string(), saved_path: None, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, ThreadItem::ContextCompaction { id: "compact-1".to_string(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 756c50869dc0..cd36b4674f30 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2549,6 +2549,9 @@ async fn inactive_thread_file_change_approval_recovers_buffered_changes() { diff: "hello\n".to_string(), }], status: codex_app_server_protocol::PatchApplyStatus::InProgress, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, }), ) @@ -4662,6 +4665,9 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { model: None, reasoning_effort: None, agents_states: HashMap::new(), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, }, ), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bebdbf12480d..9258c439ca23 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4252,6 +4252,7 @@ impl ChatWidget { call_id, query, action, + .. } = ev; let mut handled = false; if let Some(cell) = self @@ -4290,6 +4291,9 @@ impl ChatWidget { model, reasoning_effort, agents_states, + started_at_ms, + completed_at_ms, + duration_ms, } = item else { return; @@ -4348,6 +4352,9 @@ impl ChatWidget { .unwrap_or_else(|| { AgentStatus::Errored("Agent spawn failed".into()) }), + started_at_ms, + completed_at_ms, + duration_ms, }, spawn_request.as_ref(), )); @@ -4376,6 +4383,9 @@ impl ChatWidget { .unwrap_or_else(|| { AgentStatus::Errored("Agent interaction failed".into()) }), + started_at_ms, + completed_at_ms, + duration_ms, }, )); } @@ -4394,6 +4404,7 @@ impl ChatWidget { receiver_agent_role: first_receiver_metadata .as_ref() .and_then(|metadata| metadata.agent_role.clone()), + started_at_ms, }, )); } else { @@ -4415,6 +4426,9 @@ impl ChatWidget { .unwrap_or_else(|| { AgentStatus::Errored("Agent resume failed".into()) }), + started_at_ms, + completed_at_ms, + duration_ms, }, )); } @@ -4436,6 +4450,7 @@ impl ChatWidget { &self.collab_agent_metadata, ), call_id: id, + started_at_ms, }, )); } else { @@ -4450,6 +4465,9 @@ impl ChatWidget { call_id: id, agent_statuses, statuses, + started_at_ms, + completed_at_ms, + duration_ms, }, )); } @@ -4469,6 +4487,9 @@ impl ChatWidget { receiver_agent_role: first_receiver_metadata .as_ref() .and_then(|metadata| metadata.agent_role.clone()), + started_at_ms, + completed_at_ms, + duration_ms, status: receiver_thread_ids .iter() .find_map(|thread_id| agents_states.get(thread_id)) @@ -6519,6 +6540,8 @@ impl ChatWidget { command_actions, aggregated_output, exit_code, + started_at_ms, + completed_at_ms, duration_ms, } => { if matches!( @@ -6537,6 +6560,7 @@ impl ChatWidget { .collect(), source: source.to_core(), interaction_input: None, + started_at_ms, }); } else { let aggregated_output = aggregated_output.unwrap_or_default(); @@ -6552,6 +6576,8 @@ impl ChatWidget { .collect(), source: source.to_core(), interaction_input: None, + started_at_ms, + completed_at_ms, stdout: String::new(), stderr: String::new(), aggregated_output: aggregated_output.clone(), @@ -6581,6 +6607,9 @@ impl ChatWidget { id, changes, status, + started_at_ms, + completed_at_ms, + duration_ms, } => { if !matches!( status, @@ -6610,6 +6639,9 @@ impl ChatWidget { codex_protocol::protocol::PatchApplyStatus::Failed } }, + started_at_ms, + completed_at_ms, + duration_ms, }); } } @@ -6621,6 +6653,8 @@ impl ChatWidget { mcp_app_resource_uri, result, error, + started_at_ms, + completed_at_ms, duration_ms, .. } => { @@ -6632,6 +6666,8 @@ impl ChatWidget { arguments: Some(arguments), }, mcp_app_resource_uri, + started_at_ms, + completed_at_ms, duration: Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64), result: match (result, error) { (_, Some(error)) => Err(error.message), @@ -6648,9 +6684,17 @@ impl ChatWidget { }, }); } - ThreadItem::WebSearch { id, query, action } => { + ThreadItem::WebSearch { + id, + query, + action, + started_at_ms, + completed_at_ms, + duration_ms, + } => { self.on_web_search_begin(WebSearchBeginEvent { call_id: id.clone(), + started_at_ms, }); self.on_web_search_end(WebSearchEndEvent { call_id: id, @@ -6658,6 +6702,9 @@ impl ChatWidget { action: action .map(web_search_action_to_core) .unwrap_or(codex_protocol::models::WebSearchAction::Other), + started_at_ms, + completed_at_ms, + duration_ms, }); } ThreadItem::ImageView { id, path } => { @@ -6669,6 +6716,9 @@ impl ChatWidget { revised_prompt, result, saved_path, + started_at_ms, + completed_at_ms, + duration_ms, } => { self.on_image_generation_end(ImageGenerationEndEvent { call_id: id, @@ -6676,6 +6726,9 @@ impl ChatWidget { revised_prompt, status, saved_path, + started_at_ms, + completed_at_ms, + duration_ms, }); } ThreadItem::EnteredReviewMode { review, .. } => { @@ -6700,6 +6753,9 @@ impl ChatWidget { model, reasoning_effort, agents_states, + started_at_ms, + completed_at_ms, + duration_ms, } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { id, tool, @@ -6710,6 +6766,9 @@ impl ChatWidget { model, reasoning_effort, agents_states, + started_at_ms, + completed_at_ms, + duration_ms, }), ThreadItem::DynamicToolCall { .. } => {} } @@ -7138,6 +7197,7 @@ impl ChatWidget { process_id, source, command_actions, + started_at_ms, .. } => { self.on_exec_command_begin(ExecCommandBeginEvent { @@ -7152,14 +7212,21 @@ impl ChatWidget { .collect(), source: source.to_core(), interaction_input: None, + started_at_ms, }); } - ThreadItem::FileChange { id, changes, .. } => { + ThreadItem::FileChange { + id, + changes, + started_at_ms, + .. + } => { self.on_patch_apply_begin(PatchApplyBeginEvent { call_id: id, turn_id: notification.turn_id, auto_approved: false, changes: file_update_changes_to_core(changes), + started_at_ms, }); } ThreadItem::McpToolCall { @@ -7168,6 +7235,7 @@ impl ChatWidget { tool, arguments, mcp_app_resource_uri, + started_at_ms, .. } => { self.on_mcp_tool_call_begin(McpToolCallBeginEvent { @@ -7178,13 +7246,24 @@ impl ChatWidget { arguments: Some(arguments), }, mcp_app_resource_uri, + started_at_ms, }); } - ThreadItem::WebSearch { id, .. } => { - self.on_web_search_begin(WebSearchBeginEvent { call_id: id }); + ThreadItem::WebSearch { + id, started_at_ms, .. + } => { + self.on_web_search_begin(WebSearchBeginEvent { + call_id: id, + started_at_ms, + }); } - ThreadItem::ImageGeneration { id, .. } => { - self.on_image_generation_begin(ImageGenerationBeginEvent { call_id: id }); + ThreadItem::ImageGeneration { + id, started_at_ms, .. + } => { + self.on_image_generation_begin(ImageGenerationBeginEvent { + call_id: id, + started_at_ms, + }); } ThreadItem::CollabAgentToolCall { id, @@ -7196,6 +7275,9 @@ impl ChatWidget { model, reasoning_effort, agents_states, + started_at_ms, + completed_at_ms, + duration_ms, } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { id, tool, @@ -7206,6 +7288,9 @@ impl ChatWidget { model, reasoning_effort, agents_states, + started_at_ms, + completed_at_ms, + duration_ms, }), ThreadItem::EnteredReviewMode { review, .. } => { if !from_replay { diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index f41e3b3a47b4..e5e82fb0e07b 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -192,6 +192,7 @@ mod tests { parsed_cmd: Vec::new(), source: ExecCommandSource::Agent, interaction_input: None, + started_at_ms: None, } } diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 9c2e1c83d475..0997b3ff11f8 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -15,6 +15,7 @@ async fn collab_spawn_end_shows_requested_model_and_effort() { prompt: "Explore the repo".to_string(), model: "gpt-5".to_string(), reasoning_effort: ReasoningEffortConfig::High, + started_at_ms: None, }), }); chat.handle_codex_event(Event { @@ -29,6 +30,9 @@ async fn collab_spawn_end_shows_requested_model_and_effort() { model: "gpt-5".to_string(), reasoning_effort: ReasoningEffortConfig::High, status: AgentStatus::PendingInit, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }), }); @@ -272,6 +276,9 @@ async fn live_app_server_file_change_item_started_preserves_changes() { diff: "hello\n".to_string(), }], status: AppServerPatchApplyStatus::InProgress, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -309,6 +316,8 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { }], aggregated_output: None, exit_code: None, + started_at_ms: None, + completed_at_ms: None, duration_ms: None, }, }), @@ -330,6 +339,8 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { }], aggregated_output: Some("Hello, world!\n".to_string()), exit_code: Some(0), + started_at_ms: None, + completed_at_ms: None, duration_ms: Some(5), }, }), @@ -386,6 +397,9 @@ async fn live_app_server_collab_wait_items_render_history() { model: None, reasoning_effort: None, agents_states: HashMap::new(), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -423,6 +437,9 @@ async fn live_app_server_collab_wait_items_render_history() { }, ), ]), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -458,6 +475,9 @@ async fn live_app_server_collab_spawn_completed_renders_requested_model_and_effo model: Some("gpt-5".to_string()), reasoning_effort: Some(ReasoningEffortConfig::High), agents_states: HashMap::new(), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -483,6 +503,9 @@ async fn live_app_server_collab_spawn_completed_renders_requested_model_and_effo message: None, }, )]), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index ec478724860c..86a7ca1b3d46 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -368,6 +368,8 @@ async fn exec_end_without_begin_uses_event_command() { cwd, parsed_cmd, source: ExecCommandSource::Agent, + started_at_ms: None, + completed_at_ms: None, interaction_input: None, stdout: "done".to_string(), stderr: String::new(), @@ -954,6 +956,9 @@ async fn image_generation_call_adds_history_cell() { revised_prompt: Some("A tiny blue square".into()), result: "Zm9v".into(), saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }), }); @@ -1540,6 +1545,7 @@ async fn apply_patch_events_emit_history_cells() { turn_id: "turn-c1".into(), auto_approved: true, changes: changes2, + started_at_ms: None, }; chat.handle_codex_event(Event { id: "s1".into(), @@ -1569,6 +1575,9 @@ async fn apply_patch_events_emit_history_cells() { success: true, changes: end_changes, status: CorePatchApplyStatus::Completed, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }; chat.handle_codex_event(Event { id: "s1".into(), @@ -1618,6 +1627,7 @@ async fn apply_patch_manual_approval_adjusts_header() { turn_id: "turn-c1".into(), auto_approved: false, changes: apply_changes, + started_at_ms: None, }), }); @@ -1671,6 +1681,7 @@ async fn apply_patch_manual_flow_snapshot() { turn_id: "turn-c1".into(), auto_approved: false, changes: apply_changes, + started_at_ms: None, }), }); let approved_lines = drain_insert_history(&mut rx) @@ -1784,6 +1795,7 @@ async fn apply_patch_full_flow_integration_like() { turn_id: "turn-call-1".into(), auto_approved: false, changes: changes2, + started_at_ms: None, }), }); let mut end_changes = HashMap::new(); @@ -1801,6 +1813,9 @@ async fn apply_patch_full_flow_integration_like() { success: true, changes: end_changes, status: CorePatchApplyStatus::Completed, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 015340bbb2a5..138480967d61 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -506,6 +506,7 @@ pub(super) fn begin_exec_with_source( parsed_cmd, source, interaction_input, + started_at_ms: None, }; chat.handle_codex_event(Event { id: call_id.to_string(), @@ -531,6 +532,7 @@ pub(super) fn begin_unified_exec_startup( parsed_cmd: Vec::new(), source: ExecCommandSource::UnifiedExecStartup, interaction_input: None, + started_at_ms: None, }; chat.handle_codex_event(Event { id: call_id.to_string(), @@ -647,6 +649,7 @@ pub(super) fn end_exec( source, interaction_input, process_id, + started_at_ms, } = begin_event; chat.handle_codex_event(Event { id: call_id.clone(), @@ -659,6 +662,8 @@ pub(super) fn end_exec( parsed_cmd, source, interaction_input, + started_at_ms, + completed_at_ms: None, stdout: stdout.to_string(), stderr: stderr.to_string(), aggregated_output: aggregated.clone(), 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 530629b2aed5..338915804ef9 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2818,6 +2818,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::Agent, interaction_input: None, + started_at_ms: None, }), }); chat.handle_codex_event(Event { @@ -2831,6 +2832,8 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { parsed_cmd, source: ExecCommandSource::Agent, interaction_input: None, + started_at_ms: None, + completed_at_ms: None, stdout: String::new(), stderr: String::new(), aggregated_output: String::new(), diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 293c80fcf0c4..7b26c8aef1b1 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -215,6 +215,7 @@ pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistor receiver_agent_role, prompt, status: _, + .. } = ev; let title = title_with_agent( @@ -240,6 +241,7 @@ pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { receiver_thread_ids, receiver_agents, call_id: _, + .. } = ev; let receiver_agents = merge_wait_receivers(&receiver_thread_ids, receiver_agents); @@ -271,6 +273,7 @@ pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { sender_thread_id: _, agent_statuses, statuses, + .. } = ev; let details = wait_complete_lines(&statuses, &agent_statuses); collab_event(title_text("Finished waiting"), details) @@ -284,6 +287,7 @@ pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell { receiver_agent_nickname, receiver_agent_role, status: _, + .. } = ev; collab_event( @@ -307,6 +311,7 @@ pub(crate) fn resume_begin(ev: CollabResumeBeginEvent) -> PlainHistoryCell { receiver_thread_id, receiver_agent_nickname, receiver_agent_role, + .. } = ev; collab_event( @@ -331,6 +336,7 @@ pub(crate) fn resume_end(ev: CollabResumeEndEvent) -> PlainHistoryCell { receiver_agent_nickname, receiver_agent_role, status, + .. } = ev; collab_event( @@ -612,6 +618,9 @@ mod tests { model: "gpt-5".to_string(), reasoning_effort: ReasoningEffortConfig::High, status: AgentStatus::PendingInit, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, Some(&SpawnRequestSummary { model: "gpt-5".to_string(), @@ -627,6 +636,9 @@ mod tests { receiver_agent_role: Some("explorer".to_string()), prompt: "Please continue and return the answer only.".to_string(), status: AgentStatus::Running, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }); let waiting = waiting_begin(CollabWaitingBeginEvent { @@ -638,6 +650,7 @@ mod tests { agent_role: Some("explorer".to_string()), }], call_id: "call-wait".to_string(), + started_at_ms: None, }); let mut statuses = HashMap::new(); @@ -664,6 +677,9 @@ mod tests { }, ], statuses, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }); let close = close_end(CollabCloseEndEvent { @@ -673,6 +689,9 @@ mod tests { receiver_agent_nickname: Some("Robie".to_string()), receiver_agent_role: Some("explorer".to_string()), status: AgentStatus::Completed(Some("39916800".to_string())), + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }); let snapshot = [spawn, send, waiting, finished, close] @@ -750,6 +769,9 @@ mod tests { model: "gpt-5".to_string(), reasoning_effort: ReasoningEffortConfig::High, status: AgentStatus::PendingInit, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }, Some(&SpawnRequestSummary { model: "gpt-5".to_string(), @@ -783,6 +805,9 @@ mod tests { receiver_agent_nickname: Some("Robie".to_string()), receiver_agent_role: Some("explorer".to_string()), status: AgentStatus::Interrupted, + started_at_ms: None, + completed_at_ms: None, + duration_ms: None, }); assert_snapshot!("collab_resume_interrupted", cell_to_text(&cell));